This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 24e067aeef0 Record the Go SDK's Mixed Lang Task and Native Dag task 
interfaces (#72043)
24e067aeef0 is described below

commit 24e067aeef058cb66681f5a01551b3d7ba145d09
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Wed Sep 16 22:31:22 2026 +0800

    Record the Go SDK's Mixed Lang Task and Native Dag task interfaces (#72043)
---
 .../adr/lang-sdk/0008-control-flow-constructs.md   |  96 +++++++++++
 .../0009-provider-operators-as-generated-dsl.md    |  82 +++++++++
 airflow-core/adr/lang-sdk/README.md                |   2 +
 go-sdk/README.md                                   |   9 +
 .../adr/0006-mixed-lang-task-handler-interface.md  | 171 ++++++++++++++++++
 go-sdk/adr/0007-native-dag-interface.md            | 191 +++++++++++++++++++++
 6 files changed, 551 insertions(+)

diff --git a/airflow-core/adr/lang-sdk/0008-control-flow-constructs.md 
b/airflow-core/adr/lang-sdk/0008-control-flow-constructs.md
new file mode 100644
index 00000000000..c8599c4f551
--- /dev/null
+++ b/airflow-core/adr/lang-sdk/0008-control-flow-constructs.md
@@ -0,0 +1,96 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# ADR-0008: Control-Flow Constructs in Lang SDKs
+
+## Status
+
+Proposed.
+
+## Decision
+
+1. **Name each construct after the host language's control flow**, Conditional 
skipping and branching are `if`/`else` and `switch`/`case` in most languages; 
the `Operator` suffix and the `Python` infix carry nothing an author of another 
language needs.
+   `If`, with or without `Else`, and `Switch` all serialize as a branch 
operator.
+2. **A branch selects a task, not a string.** A case is the task reference the 
SDK already handed back, and the value on the wire is that task's task_id, so 
the compiler checks the candidate exists and no label has to be kept in step 
with it.
+3. **No default case.** `BranchPythonOperator` has none to serialize, and a 
one-sided `If` whose condition is false follows nothing at all.
+   A decider returning a ref that is not one of the declared cases is a 
run-time error the SDK raises, a narrower check than `skip_all_except`, which 
only rejects a task_id missing from the whole Dag.
+4. **Triggering a Dag run is an ordinary DSL task**, it is pure DSL purpose 
instead of a new runtime.
+5. **Grouping keeps Python's semantics**: a scope offering the same task and 
nesting methods as the Dag, prefixing each task_id with the group id 
(`prefix_group_id`),
+   and can be ordered against a task or another group, as `group1 >> group2` 
does in Python.
+
+## Context
+
+A native Dag interface starts with "register a task, declare an edge".
+Four constructs follow immediately, because Python Dags use them everywhere:
+
+- grouping (`TaskGroup`)
+- conditional skipping (`ShortCircuitOperator`, `@task.short_circuit`)
+- branching (`BranchPythonOperator`, `@task.branch`)
+- triggering another Dag's run (`TriggerDagRunOperator`)
+
+Every Lang SDK has to decide how to spell them, and copying Python's class 
names is the tempting default but the existing convention of Python SDK might 
not be straightforward for other Lang SDKs.
+For example `dag.ShortCircuitOperator(...)` asks an author who has never seen 
Airflow to learn Python's operator taxonomy in order to write an `if`.
+Feeling native to its own language matters more than the SDKs looking alike.
+
+## Example
+
+The Go SDK spelling of all four. Every callable takes `airflow.Context` first, 
and a decider returns the case it picked, which the SDK sends as that task's 
task_id:
+
+```go
+func hasRows(actx airflow.Context, rows Rows) (bool, error)
+func pickPath(actx airflow.Context) (*airflow.TaskRef, error)
+```
+
+```go
+group := dag.TaskGroup("transform")
+cleaned := group.Task(cleanRows)
+validated := group.Task(validateRows, airflow.Inputs(cleaned))
+
+loadIfReadyRef := dag.Task(loadIfReady)
+loadFallbackRef := dag.Task(loadFallback)
+handleLongRef := dag.Task(handleLong)
+handleShortRef := dag.Task(handleShort)
+
+gate := dag.If(hasRows, airflow.Inputs(validated)) // BranchOperator: skips 
the side not taken
+gate.Then(loadIfReadyRef)
+gate.Else(loadFallbackRef)
+
+pick := dag.Switch(pickPath) // task_id pickPath, from the function name
+pick.Case(handleLongRef).
+    Case(handleShortRef)
+
+dag.Task(airflow.TriggerDagRun(airflow.TriggerDagRunSpec{DagId: 
"downstream_etl"}), airflow.TaskSpec{TaskId: "trigger_downstream"}).After(gate)
+```
+
+A decider has to see the refs it returns, so either they are package-level or 
it is a closure where the Dag is built.
+
+A one-sided `If` is a branch with one candidate rather than a 
`ShortCircuitOperator`.
+That operator defaults to skipping every task in its downstream closure and 
ignoring their trigger rules, where a branch skips only the immediate 
downstream it did not take and keeps a task that several branches converge on 
running.
+
+## Consequences
+
+- **A branch selects exactly one task.** Python's branch callable may return a 
list of task_ids, and `skip_all_except` handles it;
+  a single-ref return cannot express that, and no Lang SDK offers it for now.
+  An author needing several paths together puts them behind one task, or gates 
each with its own condition.
+  This limitation is accepted rather than open.
+- **No Lang SDK needs a deferral mechanism for now** to offer `deferrable` or 
`wait_for_completion`, because the trigger task runs in Python.
+- **Nothing extra reaches the Dag JSON**, which carries no branch-candidate 
field at all. A ref is a task_id by the time the decision is sent, so each SDK 
stores its cases and nothing else.
+- **A group edge needs one base type per SDK** that both a task and a group 
satisfy, since either can sit at the end of an edge.
+  Python already has it: `TaskGroup(TaskGroupMixin, DAGNode)` 
(`task-sdk/src/airflow/sdk/definitions/taskgroup.py:96`) and every operator 
inherit `DependencyMixin` (`.../definitions/_internal/mixins.py:35`), where 
`set_upstream` and `set_downstream` live.
+  The Go shape is `airflow.Node` 
([`go-sdk/adr/0007`](../../../go-sdk/adr/0007-native-dag-interface.md)).
diff --git 
a/airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md 
b/airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md
new file mode 100644
index 00000000000..035e4f1f78b
--- /dev/null
+++ b/airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md
@@ -0,0 +1,82 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# ADR-0009: Provider Operators as Generated Lang-SDK DSL
+
+## Status
+
+Proposed.
+
+## Decision
+
+1. **Provider operators reach every Lang SDK as generated, serialization-only 
bindings.**
+   Only a task wrapping a host-language function executes in that language; a 
generated operator carries no body.
+   Both go through the SDK's ordinary task registration.
+2. **A provider DSL task runs on a Python worker**, so it must not inherit the 
SDK's queue, and the deployment must have that provider installed.
+3. **Generate from the Python constructors, commit the output, and guard it 
with a prek hook.**
+4. **Generate an operator only when every required constructor parameter is 
JSON-serializable**
+   (primitive, list, dict, or a nested spec of those); omit optional 
parameters that are not;
+   skip entirely any operator requiring a callable or a live object.
+5. **The namespace mirrors `providers/`, adapted to each language's naming 
rules.**
+6. **Templated fields pass through untouched.** The SDK writes the Jinja 
string; rendering stays server-side, where it already happens.
+7. **Version skew warns at Dag parsing time and never blocks execution.** 
Warning on SDK provider DSL version and the server-side Python provider runtime 
version mismatch, shouldn't be a fatal error.
+8. **Bindings ship as one package per provider from day one**, on that 
provider's release cadence, with an aggregate pin published alongside.
+
+## Context
+
+The design review on #72043 asked whether authoring a Dag in Go means giving 
up Python provider operators.
+
+**It must not**, for any language: Airflow's ~100 provider distributions are 
what a native Lang-SDK Dag cannot afford to lose, and what no workflow engine 
outside Airflow's ecosystem can offer.
+A native Dag serializes into the same Dag JSON a Python Dag produces, so any 
operator whose
+constructor arguments are JSON-representable can be expressed from another 
language as a DSL that
+emits serialization and nothing else.
+
+## Example
+
+The Go SDK spelling, mixing a native task with two generated operators:
+
+```go
+import (
+    "github.com/apache/airflow/go-sdk/airflowprovider/amazon"
+    "github.com/apache/airflow/go-sdk/airflowprovider/cncf/kubernetes"
+)
+
+extracted := dag.Task(extract) // native Go: runs on a Go worker
+
+staged := dag.Task(amazon.S3ToRedshiftOperator{
+    SchemaName: "public", TableName: "events", S3Bucket: "raw", S3Key: 
"events/{{ ds }}",
+}, airflow.TaskSpec{TaskId: "stage"}).After(extracted) // DSL only: runs on a 
Python worker
+
+dag.Task(kubernetes.KubernetesPodOperator{
+    Namespace: "airflow", Image: "report:latest", Name: "report",
+}, airflow.TaskSpec{TaskId: "report"}).After(staged)
+```
+
+`airflow.TriggerDagRun` ([ADR-0008](0008-control-flow-constructs.md)) follows 
the same concept but it is the hand-written.
+
+## Consequences
+
+- **Authors should pin the DSL to the provider version their deployment has 
installed.** The bindings
+  describe one provider version's constructors, the operator that actually 
runs is whatever the
+  Python worker imports, and skew between them only warns. Matching the two is 
what keeps that
+  warning from becoming a surprise at run time.
+- **Most skew is harmless, so it must not be fatal.** Python already fails 
loudly and precisely when
+  a class or argument genuinely is not there, and a parse-time hard failure 
would take a whole Dag
+  out over a version difference its tasks may not even touch. The hook also 
emits a coverage report,
+  so which operators each SDK can reach is reviewable.
diff --git a/airflow-core/adr/lang-sdk/README.md 
b/airflow-core/adr/lang-sdk/README.md
index b6768d61396..359275ac269 100644
--- a/airflow-core/adr/lang-sdk/README.md
+++ b/airflow-core/adr/lang-sdk/README.md
@@ -33,6 +33,8 @@ bind core interfaces and apply to every language SDK, not 
just the Java SDK.
 - [ADR-0005](0005-coordinator-packaging.md): coordinator packaging, module 
layout, and registration.
 - [ADR-0006](0006-no-lang-sdk-source-display.md): no Lang-SDK source display 
for mixed-language (`@task.stub`) Dags.
 - [ADR-0007](0007-taskflow-across-language-boundary.md): TaskFlow across the 
language boundary — argument binding for Lang-SDK tasks.
+- [ADR-0008](0008-control-flow-constructs.md): control-flow constructs — 
grouping, conditional skipping, branching, and triggering a Dag run.
+- [ADR-0009](0009-provider-operators-as-generated-dsl.md): provider operators 
as generated, serialization-only DSL in every Lang SDK.
 
 Decisions specific to a single SDK stay next to that SDK — for example, the Go 
SDK's bundle-format
 decisions live in [`go-sdk/adr/`](../../../go-sdk/adr). Java-SDK-only 
interface-design decisions —
diff --git a/go-sdk/README.md b/go-sdk/README.md
index 8fb6564c9e7..14e44f3c531 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -323,9 +323,18 @@ The [`adr/`](./adr) directory records the design decisions 
behind the SDK:
   the executable *is* the bundle.
 - [ADR 0005](./adr/0005-retire-go-edge-worker.md): retire the standalone Go 
Edge Worker and make the
   coordinator the only execution path.
+- [ADR 0006](./adr/0006-mixed-lang-task-handler-interface.md): bundle 
registration and the Mixed Lang
+  task handler interface — `airflow.Bundle`/`Register`/`Serve`; Cross language 
TaskFlow: flat positional binding, `arg:` tagged structs, and the untagged 
folded-name fallback.
+- [ADR 0007](./adr/0007-native-dag-interface.md): the proposed Native Dag 
interface (`airflow.Dag`/
+  `dag.Task`/`airflow.Inputs`/`Before`-`After`)
 
 Cross-cutting Lang-SDK decisions — the coordinator architecture and how 
non-Python tasks integrate with
 Airflow core surfaces — are recorded in 
[`airflow-core/adr/lang-sdk/`](../airflow-core/adr/lang-sdk).
+Two of them shape the interfaces above:
+[ADR-0008](../airflow-core/adr/lang-sdk/0008-control-flow-constructs.md) for 
grouping, conditions,
+branching, and triggering a Dag run, and
+[ADR-0009](../airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md)
 for reaching
+Python provider operators from a native Dag.
 
 The normative, language-agnostic on-disk bundle format (the footer layout, 
manifest fields, and what the
 `ExecutableCoordinator` reads) is specified in
diff --git a/go-sdk/adr/0006-mixed-lang-task-handler-interface.md 
b/go-sdk/adr/0006-mixed-lang-task-handler-interface.md
new file mode 100644
index 00000000000..6150ca4c2fc
--- /dev/null
+++ b/go-sdk/adr/0006-mixed-lang-task-handler-interface.md
@@ -0,0 +1,171 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# 6. Bundle registration and Mixed Lang task handlers
+
+Date: 2026-09-09
+
+## Status
+
+Proposed.
+
+## Decision
+
+1. **A "bundle" is a value the author builds.** `airflow.Bundle()` returns a 
`*airflow.BundleRef`;
+   `main` reads build, register, serve, with `bundle.Serve()` as its last 
statement.
+   It replaces `BundleProvider` and `Registry`, the callback and the write 
half of the same bundle.
+2. **`bundle.Register(items ...airflow.Registraterable)`** is the single 
registration verb, taking native Dags and task handlers.
+3. **A Go bundle registers task handlers, not Dags**: 
`airflow.TaskHandler(dagId, taskId, fn)`, the Go body for a task Python 
declares with `@task.stub`.
+4. **Both dag_id and task_id are written out on TaskHandler definition**, 
because Python owns them; nothing is derived from the Go function name.
+5. **Every handler must take an `airflow.Context` first**: a struct embedding 
`context.Context`, exposing `Logger()`, `Client()`, `TaskInstance()`, and 
`DagRun()`.
+   What Airflow supplies a task arrives as a method on that value rather than 
as a parameter of its own.
+6. **Every remaining parameter is data**, bound positionally, or by field when 
it is a single struct: `arg:"..."` when tagged, else the folded Go field name.
+
+## Context
+
+Python owns everything but the body of a Mixed Lang task: `@task.stub` 
declares the task, its arguments, and its place in the graph.
+The Go side has no Dag to define, so Dag vocabulary misleads.
+
+Renaming the Go function must not change which task body Airflow matches, so 
`TaskHandler` names the dag_id and the task_id explicitly instead of inferring 
them from the Go function name.
+Additionally, the TaskHandler shouldn't accept any spec as it should only 
define the implementation of stub operator, so the `airflow.TaskHandler(dag_id, 
task_id, fn)` is a much cleaner interface.
+
+Registration is inverted today. An author declares a struct with no state, 
asserts it implements
+`v1.BundleProvider`, fills in `RegisterDags(dagbag v1.Registry) error`, and 
hands the struct to
+`bundlev1server.Serve` — three concepts and an empty type before a single task 
is declared.
+
+The term naming should be refined to reduce the new terminologies across user 
interface.
+The `Registry` should be `Bundle` and the `AddDag` is mis-used for registering 
the TaskHandler.
+
+The shipped signature (#70209) injects `sdk.TIRunContext`, `*slog.Logger`, and 
`sdk.Client` by type.
+Calling them still needs the `context.Context` passed in by hand, which is 
awkward from a Go author's perspective.
+Exposing the logger and the client on the context itself removes that, and 
`airflow.Context` in the Signature section below is that shape.
+Both keep taking a context, so a call reads `actx.Logger().InfoContext(actx, 
...)` or `actx.Client().GetVariable(actx, ...)` and neither holds one in a 
field.
+
+## Example
+
+```go
+func main() {
+    bundle := airflow.Bundle()
+
+    bundle.Register(
+        airflow.TaskHandler("py_etl", "transform", transform),
+        airflow.TaskHandler("py_etl", "via_struct_arg_tag", ViaStructArgTag),
+        airflow.TaskHandler("py_etl", "via_struct", ViaStruct),
+    )
+
+    if err := bundle.Serve(); err != nil {
+        log.Fatal(err)
+    }
+}
+```
+
+Registration can be spread across packages, either by passing the bundle along 
or by returning `[]airflow.Registraterable` for the caller: 
`bundle.Register(taskflowbinding.Handlers()...)`.
+
+Three ways a Go function receives a stub task's data, all live in 
`go-sdk/example/bundle/`.
+
+**Flat positional**, for `def transform(country: str, extracted: dict)`:
+
+```go
+func transform(actx airflow.Context, country string, extracted map[string]any) 
error {
+    actx.Logger().InfoContext(actx, "transforming", "country", country, "try", 
actx.TaskInstance().TryNumber)
+
+    threshold, err := actx.Client().GetVariable(actx, "etl_threshold")
+    if err != nil {
+        return err
+    }
+    return writeRows(actx, extracted, threshold)
+}
+```
+
+**A single `arg:`-tagged struct**, for `def via_struct_arg_tag(region_code: 
str, threshold: float)`:
+
+```go
+type ViaStructArgTagInput struct {
+    Region    string  `arg:"region_code"`
+    Threshold float64 `arg:"threshold"`
+}
+
+func ViaStructArgTag(actx airflow.Context, input ViaStructArgTagInput) (any, 
error)
+```
+
+**A single untagged struct**, where the field name folds to the Python 
argument, for
+`def via_struct(region_code: str, threshold: float)`:
+
+```go
+type ViaStructInput struct {
+    RegionCode string  // folds to region_code
+    Threshold  float64 // folds to threshold
+}
+
+func ViaStruct(actx airflow.Context, input ViaStructInput) (any, error)
+```
+
+Folding lowercases a name and strips its underscores, on both sides: 
`RegionCode` and `region_code`
+both fold to `regioncode`.
+
+## Signature
+
+```go
+package airflow
+
+func Bundle() *BundleRef
+
+func (b *BundleRef) Register(items ...Registraterable)
+func (b *BundleRef) Serve() error
+
+// Registraterable is sealed: its only method is unexported, so the set of 
things a bundle
+// accepts stays closed to the SDK's own types — task handlers today, a Dag 
authored in Go
+// once there is one.
+type Registraterable interface{ registraterable() }
+
+func TaskHandler(dagId, taskId string, fn any) Registraterable
+
+// Context is what every handler takes first. It is a context, so it passes 
straight to the
+// logger and the client rather than being stored inside either of them.
+type Context struct {
+    context.Context
+    // unexported fields
+}
+
+func (c Context) Logger() *slog.Logger
+func (c Context) Client() sdk.Client
+func (c Context) TaskInstance() TaskInstance
+func (c Context) DagRun() DagRun
+
+// FromContext recovers the SDK surface inside a helper typed as a plain 
context.Context.
+func FromContext(ctx context.Context) (Context, bool)
+```
+
+## Consequences
+
+- **Registration closes when `Serve` is called.** Registering afterwards is a 
programming error and panics.
+- Graceful termination needs no unwrapping — `actx.Done()` fires on supervisor 
shutdown, and
+  `http.NewRequestWithContext(actx, ...)` accepts it — while cleanup that must 
outlive cancellation
+  uses `context.WithoutCancel(actx)`.
+
+## Alternatives
+
+- **`airflow.TaskHandler(dagId, fn, airflow.WithTaskId(...))`**, defaulting 
the task_id to the Go
+  function name. Rejected: see the ids in Context above.
+- **Package-level accessors over a plain `context.Context`** 
(`airflow.Logger(ctx)`, `airflow.Client(ctx)`), leaving the handler's first 
parameter as `context.Context`.
+  Rejected: `airflow.Logger(ctx)` reads oddly next to `actx.Logger()`, asking 
the package for something the context already holds.
+  It also keeps the SDK surface in package functions instead of on the value, 
and a context from anywhere else still compiles, so a missing logger or client 
only shows up when the task runs.
+- **A logger and a client that hold the context themselves**, so a call reads 
`actx.Logger().Info(...)` with nothing passed. Rejected: that is what puts a 
request-scoped context in a struct field, which the context package warns 
against.
+  `airflow.Context` carrying one is a different case, since it is a 
purpose-built context rather than a domain type, which is why the context 
itself stays a struct.
+- **Two registration verbs**, one per registerable kind. Rejected: Having 
`bundle.registerTaskHandler(airflow.TaskHandler(...))` spell the exact term 
twice, having a sealed type is a much cleaner interface.
diff --git a/go-sdk/adr/0007-native-dag-interface.md 
b/go-sdk/adr/0007-native-dag-interface.md
new file mode 100644
index 00000000000..de1ff773a6f
--- /dev/null
+++ b/go-sdk/adr/0007-native-dag-interface.md
@@ -0,0 +1,191 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# 7. Native Dag interface
+
+Date: 2026-09-09
+
+## Status
+
+Proposed.
+
+## Decision
+
+1. **One Dag type, constructed then registered.** `airflow.Dag(dagId, spec)` 
returns a `*airflow.DagRef` that is complete before `bundle.Register(dag)` 
takes it — the same verb that registers Mixed Lang task handlers ([ADR 
6](0006-mixed-lang-task-handler-interface.md)).
+   Naming rule: `airflow.X(...)` constructs, `*airflow.XRef` is the entity; 
what every Dag must have is a positional parameter for dag_id, and the rest 
travels in a spec struct.
+2. **Tasks register through `dag.Task(fn any, opts ...airflow.TaskOption)`**, 
returning a `*airflow.TaskRef`. `airflow.Inputs(...)` and a bare 
`airflow.TaskSpec{}` both implement `TaskOption`.
+3. **At most one `airflow.TaskSpec` per task.** A second one is a registration 
error rather than something to merge, so a task's attributes are only ever 
written in one place.
+4. **task_id is the Go function name by default**, spelled exactly as the 
function is, and `airflow.TaskSpec{TaskId: ...}` sets it to anything else.
+5. **`airflow.Inputs(refs...)` declares the data and the edge in one call** 
for defining graph with TaskFlow syntax.
+6. **`Before` and `After` are order-only edges on `airflow.Node`**, which both 
`*airflow.TaskRef` and `*airflow.TaskGroupRef` satisfy. They are the Go pair 
for `>>` and `<<`, and both return their argument set as one `Node`, so 
`a.Before(b, c).Before(d)` is Python's `a >> [b, c] >> d`.
+7. **An edge label wraps the endpoint**: `loaded.Before(airflow.Label(notify, 
"when empty"))` is Python's `loaded >> Label("when empty") >> notify`. 
Labelling the endpoint rather than the call lets one fan-out give each edge its 
own label.
+8. **Trigger rules belong to the task**, as `airflow.TaskSpec{TriggerRule: 
...}`, never to an edge.
+9. **A user-facing enum carries its type in the constant name** — e.g. 
`airflow.TriggerRuleAllDone`.
+10. **Everything an author writes comes from one `airflow` package.**
+11. **No Go-native deferral**, and none is needed: the constructs that defer 
are DSL tasks Python executes.
+12. **`DagSpec` and `TaskSpec` are generated from Airflow core's serialization 
schema** (`airflow-core/src/airflow/serialization/schema.json`) into the 
`airflow` package itself and committed, the way `models.gen.go` already is for 
the supervisor schema.
+    `TaskSpec` implements `airflow.TaskOption`, so a generated struct travels 
in the same variadic as `airflow.Inputs`.
+
+## Context
+
+A native Dag is authored entirely in Go — schedule, tasks, and dependencies — 
and serializes into the Dag JSON a Python Dag would produce.
+Dependencies between Go functions have to be typed rather than looked up by 
task ID, and a Dag should read like Go rather than transliterated Python.
+
+The interfaces sketched in #67155 and #70158 spread their surface across `v1`, 
`sdk`, and `slog`, published a half-built Dag to the registry and mutated it 
afterwards, and could declare an edge in only one direction.
+
+## Example
+
+Both forms build the same graph; which one an author writes depends on whether 
the edge carries a value.
+
+**Data dependencies — the TaskFlow equivalent.** `airflow.Inputs` passes an 
upstream's return value in and declares the edge in one call, as calling one 
TaskFlow function with another's output does in Python (`extracted = extract(); 
transformed = transform(extracted); load(transformed)`).
+
+```go
+dag := airflow.Dag("etl", airflow.DagSpec{Schedule: "@daily"})
+
+extracted := dag.Task(extract)
+transformed := dag.Task(transform, airflow.Inputs(extracted))
+dag.Task(load, airflow.Inputs(transformed), airflow.TaskSpec{Retries: 2})
+
+bundle.Register(dag)
+```
+
+The task functions, where `Result` is any type the SDK can serialize to XCom — 
the Go equivalent of what a TaskFlow function returns:
+
+```go
+type Result struct {
+    Message string `json:"message"`
+}
+
+func extract(actx airflow.Context) (Result, error) {
+    return Result{Message: "native Dag data"}, nil
+}
+
+func transform(actx airflow.Context, extracted Result) (Result, error) {
+    return Result{Message: "transformed " + extracted.Message}, nil
+}
+
+func load(actx airflow.Context, transformed Result) error { return nil }
+```
+
+The task_ids are the function names by default: `extract`, `transform`, and 
`load`.
+
+**Order-only dependencies — the `>>` and `<<` equivalent.** For tasks that 
must be ordered but exchange no data; the functions take no parameter for such 
an edge.
+
+```go
+loaded := dag.Task(load, airflow.Inputs(transformed))
+cleaned := dag.Task(cleanup, airflow.TaskSpec{TriggerRule: 
airflow.TriggerRuleAllDone})
+notified := dag.Task(notify)
+emptyNotice := dag.Task(notifyEmpty, airflow.TaskSpec{TaskId: "notify_empty"})
+staging := dag.TaskGroup("staging")  // a group carries edges like a task
+staging.Task(stageRows)              // tasks join a group through the group
+
+staging.Before(loaded)            // staging >> load
+loaded.Before(notified, cleaned)  // loaded >> [notify, cleanup]
+cleaned.After(extracted)          // cleanup << extracted
+
+loaded.Before(airflow.Label(emptyNotice, "when empty"))  // loaded >> 
Label("when empty") >> notify_empty
+```
+
+## Signature
+
+```go
+package airflow
+
+func Dag(dagId string, spec DagSpec) *DagRef
+
+func (d *DagRef) Task(fn any, opts ...TaskOption) *TaskRef
+func (d *DagRef) TaskGroup(groupId string, opts ...TaskGroupOption) 
*TaskGroupRef
+
+func (g *TaskGroupRef) Task(fn any, opts ...TaskOption) *TaskRef
+func (g *TaskGroupRef) TaskGroup(groupId string, opts ...TaskGroupOption) 
*TaskGroupRef
+
+// DagSpec and TaskSpec are generated into this package from
+// airflow-core/src/airflow/serialization/schema.json and committed.
+type DagSpec struct {
+    Schedule  string
+    StartDate time.Time
+    Catchup   bool
+    Tags      []string
+    // ...
+}
+
+// TaskSpec implements TaskOption, so it travels in the same variadic as 
Inputs.
+type TaskSpec struct {
+    TaskId      string // defaults to the Go function name
+    Retries     int
+    TriggerRule TriggerRule
+    // ...
+}
+
+// TaskOption is sealed: its only method is unexported, so a task takes 
SDK-defined options and
+// nothing else. TaskSpec and the value Inputs returns both implement it.
+type TaskOption interface{ applyTask(*taskConfig) }
+
+func Inputs(refs ...*TaskRef) TaskOption
+
+// Node is what an edge connects. *TaskRef and *TaskGroupRef implement it, 
sealed the same way.
+// It is the Go counterpart of Python's DAGNode / DependencyMixin.
+// Before and After return their argument set as one Node, which is what makes 
a chain work.
+type Node interface {
+    Before(nodes ...Node) Node
+    After(nodes ...Node) Node
+    node()
+}
+
+// Label carries an edge label into Before or After. The Node it returns 
stands for node itself.
+func Label(node Node, text string) Node
+
+// A user-facing enum is a named string type with its type in each constant 
name.
+type TriggerRule string
+
+const (
+    TriggerRuleAllSuccess TriggerRule = "all_success"
+    TriggerRuleAllDone    TriggerRule = "all_done"
+    TriggerRuleOneFailed  TriggerRule = "one_failed"
+    // ... one constant per Python trigger rule
+)
+```
+
+## Consequences
+
+- **A cycle check becomes necessary.** `b := dag.Task(B, airflow.Inputs(a)); 
b.Before(a)` is a genuine cycle in accepted
+  syntax. Either the build-time or the Dag-processing time should reject this.
+- **A count or type mismatch panics at registration**, not at run time, 
because each `*TaskRef` carries its recorded output type.
+- **An edge verb returns what it pointed at, not its receiver.** That is what 
makes `a.Before(b, c).Before(d)` mean `a >> [b, c] >> d`.
+  Returning the receiver would read like a chain and mean a second fan-out 
from `a`.
+- **The specs generate into the `airflow` package, not a `gen` package beside 
it.** An unexported method belongs to the package that declares it, so a 
generated type living elsewhere could not implement the sealed `TaskOption`, 
and a type alias cannot gain methods either.
+  Generating in place is what keeps both `airflow.TaskSpec` and the seal.
+- **The generated names need a mapping.** The core schema carries no `title` 
fields, unlike the supervisor schema `models.gen.go` reads, so its `dag` and 
`operator` definitions would generate as `Dag`, a name the constructor already 
takes, and `Operator`, which is not the SDK's vocabulary.
+  Either the schema gains titles or the generate step keeps the map.
+- **The schema is the serialized shape, not the authoring shape.** It requires 
`fileloc` and `tasks` on a Dag, and `task_type`, `_task_module`, `ui_color`, 
`ui_fgcolor`, and `template_fields` on an operator, all of which the SDK fills 
in, and it carries a serialized `timetable` object where an author writes a 
schedule.
+  Generation needs an exclusion list and a hand-written field or two, the same 
kind of rule 
[ADR-0009](../../airflow-core/adr/lang-sdk/0009-provider-operators-as-generated-dsl.md)
 states for provider operators.
+- **A data edge is labelled by redeclaring it.** Declaring an edge that 
already exists is idempotent, so `extracted.Before(airflow.Label(transformed, 
"rows"))` labels the edge `Inputs` created.
+- **Renaming a Go function renames the task.** The id is derived, and history, 
clears, and the UI all key on task_id, so renaming a function whose task 
carries no `TaskSpec` id is a Dag change.
+  `airflow.TaskSpec{TaskId: ...}` pins an id that has to outlive the 
function's name, and it is also how a Dag gets snake_case ids, since nothing 
transforms a Go name.
+
+## Alternatives
+
+- **Fetching upstream values at run time**, where a task reads an upstream 
result inside its own body
+  (`result.Get(&out)`) and the graph falls out of the order the Go code 
executes. Rejected: Airflow
+  materializes the whole graph at Dag-processing time and then invokes a 
single task instance's
+  callable per run, so an edge existing only in execution order cannot be 
parsed without running the
+  program to completion. `Inputs` keeps the typed outputs that style is 
reached for.
+- **Labelling the call**, `loaded.Before(notify).Label("when empty")`. 
Rejected: one call fans out, so a single label on the call cannot give 
`a.Before(b, c)` a different label per edge.
+- **A positional task_id**, `dag.Task(taskId, fn, opts...)`. Rejected: It's 
more straightforward and native for Go user to define a Task without defining 
the task_id explicitly. They could still set the task_id other than the 
function name in the TaskSpec.
+- **Separate `Dag` and `MixedLangDag` types.** Rejected: Python has one Dag 
class, and the Mixed Lang case is not a Dag at all ([ADR 
6](0006-mixed-lang-task-handler-interface.md)).

Reply via email to