Skip to content

Commit 1753894

Browse files
committed
docs(spec): unify Context.create() signature across SDKs (#66)
- New 6-input canonical signature: identity, trace_parent, cancel_token, data, services, global_deadline. Order is normative for positional languages. - Remove executor and caller_id from public factory inputs. executor binding moves to a new normative §"Contract: Executor binding to Context" that unifies three sources (local create, cross-process deserialize, hot-reload restore) under one rule. - Add cancel_token as a first-class parameter — eliminates 9 production post-hoc ctx.cancel_token = token sites (apcore-mcp-{python,ts}, axum-apcore, django/flask/fastapi-apcore). - Rust TraceParent struct will embed tracestate; ContextBuilder tracestate() setter removed. - New normative §"Contract: Distributed cancellation" + §"Contract: global_deadline distributed semantics" pin down cross-process behavior. - New conformance fixture conformance/fixtures/context_create.json validates cross-SDK parity (15 test cases). Folds into v0.22.0 (CHANGELOG [Unreleased] entry will graduate on next release-cut commit). No git tag for v0.22.0 yet — bundling here avoids a v0.23 minor bump. See issue #66 for the full ecosystem-wide audit. Signed-off-by: tercel <tercel.yi@gmail.com>
1 parent d5aa986 commit 1753894

5 files changed

Lines changed: 320 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### Changed
13+
14+
- **Decision D-24 — `Context.create()` signature unified across all SDKs (#66).** Reduced to the six caller-supplied fields only: `identity`, `trace_parent`, `cancel_token`, `data`, `services`, `global_deadline`. Two prior inputs are removed from the public factory surface:
15+
- `executor` — Executor self-binds at pipeline entry under the new normative §"Contract: Executor binding to Context" (`docs/features/core-executor.md`). This unifies three previously distinct binding scenarios under one rule: local construction via `Context.create()`, cross-process deserialize, and hot-reload survivor restore.
16+
- `caller_id` — zero production / test / doc callers across the 25-repo ecosystem. Top-level Contexts always have `caller_id = null`; the value is managed exclusively by `Context.child()`. Reserved name for future revisions.
17+
18+
Added `cancel_token` as a first-class parameter, eliminating the post-hoc `ctx.cancel_token = token` anti-pattern documented in 9 production sites across `apcore-mcp-{python,typescript}`, `apcore-typescript/async-task.ts` (which had to cast away `readonly`), `axum-apcore`, `django-apcore`, `fastapi-apcore`.
19+
20+
Rust `TraceParent` struct gains a `tracestate: Vec<(String, String)>` field to align with Python/TS shape; the redundant `ContextBuilder::tracestate()` setter is removed.
21+
22+
Two new normative sections clarify distributed semantics:
23+
- §"Contract: Distributed cancellation" — `cancel_token` is local-only; cross-process cancellation MUST go through out-of-band channels (e.g., AsyncTaskStore task_id lookup).
24+
- §"Contract: `global_deadline` distributed semantics" — `global_deadline` does not propagate across process boundaries; callers needing wall-clock deadline propagation SHOULD store it in `context.data` under an extension key.
25+
26+
Conformance fixture `context_create.json` validates cross-SDK parameter parity, removal of `executor`/`caller_id`, idempotent same-executor rebinding, and cross-executor conflict behavior.
27+
1228
### Added
1329

1430
- **Decision D-17 — `TaskStore` is async across all SDKs** (`docs/features/async-tasks.md` §1.1). Pluggable backends like Redis or SQL cannot satisfy a sync `TaskStore` contract without blocking the runtime's event loop. New normative: every `TaskStore` method MUST be asynchronous in Python (`async def`), TypeScript (returns `Promise<T>`), and Rust (`async fn` via `#[async_trait]`). `InMemoryTaskStore` MUST still expose async signatures even though its operations are CPU-only — uniform shape lets stores compose generically. Supersedes the partially-sync contract present in apcore-python and apcore-typescript through v0.21.x. Found via `/apcore-skills:sync` (finding A-D-AT-04).

conformance/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ SDK conformance runners **must** load `.json` files with a JSON parser. The `.ya
3838
| `middleware_on_error_recovery.json` | A11 | After-middleware error recovery: first-dict-wins, null passthrough, success non-override |
3939
| `identity_system.json` || Identity construction, field access, and context propagation (AC-014, AC-015) |
4040
| `context_trace_parent.json` | §10.5 | Context.create trace_parent strict validation: 32-hex only, W3C-invalid rejection, no auto-normalization |
41+
| `context_create.json` | Issue #66 | Context.create unified-signature contract: 6-param input list, executor/caller_id NOT inputs, Executor binding (local + deserialize + hot-reload), idempotent same-executor rebind, cross-executor conflict, child() propagation of executor + cancel_token, distributed cancel_token/global_deadline semantics, TraceParent embeds tracestate |
4142
| `dependency_version_constraints.json` | §5.3, §5.15.2 | Dependency `version` constraint enforcement: exact, `>=`, `<=`, `^`, `~`, ranges, optional skip |
4243
| `binding_errors.json` || 6 canonical cross-SDK error message parity test cases (`BindingFileInvalidError`, `BindingSchemaModeConflictError`, `BindingSchemaInferenceFailedError`, `PipelineHandlerNotSupportedError`, `BindingInvalidTargetError`, `BindingModuleNotFoundError`) |
4344
| `binding_yaml_canonical.yaml` || Cross-SDK binding YAML canonical fixture (`.yaml` format): permissive auto_schema, explicit schemas with display overlay, strict auto_schema mode |
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
{
2+
"description": "Context.create unified-signature contract across all SDKs. Validates the v0.22.0 normative input list (identity, trace_parent, cancel_token, data, services, global_deadline), the removal of executor and caller_id as inputs, the Executor binding rules (local create, cross-process deserialize, hot-reload restore), and child() propagation. See docs/features/core-executor.md §Contract: Context.create + §Contract: Executor binding to Context, and Issue #66.",
3+
"test_cases": [
4+
{
5+
"id": "create_minimal_all_defaults",
6+
"description": "Context.create() with no arguments yields a fresh top-level Context — null executor, null identity, null cancel_token, empty data, empty call_chain, null caller_id, 32-char hex trace_id.",
7+
"input": {},
8+
"expected": {
9+
"trace_id_pattern": "^[0-9a-f]{32}$",
10+
"identity": null,
11+
"executor": null,
12+
"cancel_token": null,
13+
"services": null,
14+
"global_deadline": null,
15+
"caller_id": null,
16+
"call_chain": [],
17+
"data_empty": true
18+
}
19+
},
20+
{
21+
"id": "create_with_identity_only",
22+
"description": "Most common adapter pattern: only identity supplied. trace_id auto-generated, all other caller-inputs null.",
23+
"input": {
24+
"identity": {
25+
"id": "user-42",
26+
"type": "user",
27+
"roles": ["analyst"]
28+
}
29+
},
30+
"expected": {
31+
"trace_id_pattern": "^[0-9a-f]{32}$",
32+
"identity_id": "user-42",
33+
"executor": null,
34+
"cancel_token": null
35+
}
36+
},
37+
{
38+
"id": "create_with_cancel_token",
39+
"description": "cancel_token is a first-class parameter (v0.22.0+). Verifies the token is carried on the returned Context without post-hoc assignment.",
40+
"input": {
41+
"cancel_token_handle": "TOKEN_FIXTURE_A"
42+
},
43+
"expected": {
44+
"cancel_token_bound": true,
45+
"cancel_token_matches_input": true,
46+
"executor_at_create_time": null
47+
}
48+
},
49+
{
50+
"id": "create_with_global_deadline",
51+
"description": "global_deadline is an accepted caller input. Local-only — does not affect serialize round-trip (see context_create_distributed).",
52+
"input": {
53+
"global_deadline": 1234567890.5
54+
},
55+
"expected": {
56+
"global_deadline": 1234567890.5,
57+
"executor": null
58+
}
59+
},
60+
{
61+
"id": "create_rejects_executor_input",
62+
"description": "executor MUST NOT be a public Context.create() parameter. SDKs MAY enforce by signature (no such parameter exists) or by runtime check. Either is conforming as long as 'pass executor at construction' is not possible through the public API.",
63+
"input": {
64+
"attempt_pass_executor": true
65+
},
66+
"expected": {
67+
"executor_is_not_a_parameter": true
68+
}
69+
},
70+
{
71+
"id": "create_rejects_caller_id_input",
72+
"description": "caller_id MUST NOT be a public Context.create() parameter. Top-level Contexts always have caller_id = null; the field is managed exclusively by Context.child().",
73+
"input": {
74+
"attempt_pass_caller_id": "fake.caller"
75+
},
76+
"expected": {
77+
"caller_id_is_not_a_parameter": true,
78+
"caller_id_after_create": null
79+
}
80+
},
81+
{
82+
"id": "executor_binds_on_first_call_local",
83+
"description": "Local construction → first Executor.call() binds the Executor to ctx.executor. After the call returns, ctx.executor MUST equal the Executor instance.",
84+
"input": {
85+
"construction": "Context.create()",
86+
"call_module": "test.echo"
87+
},
88+
"expected": {
89+
"executor_at_create_time": null,
90+
"executor_after_first_call_bound": true,
91+
"binding_pre_step_1": true
92+
}
93+
},
94+
{
95+
"id": "executor_binds_idempotent_same_instance",
96+
"description": "Reusing the same Context across multiple top-level Executor.call() invocations on the same Executor instance is a noop on subsequent calls — MUST NOT raise.",
97+
"input": {
98+
"construction": "Context.create()",
99+
"calls": ["test.echo", "test.echo", "test.echo"],
100+
"same_executor": true
101+
},
102+
"expected": {
103+
"rebind_noop": true,
104+
"raised_error": false,
105+
"executor_identity_stable": true
106+
}
107+
},
108+
{
109+
"id": "executor_rejects_cross_executor_rebind",
110+
"description": "If ctx.executor is already bound to Executor A and Executor B receives the same Context, B SHOULD raise ContextBindingError. SDKs that accept silently MUST document this deviation prominently.",
111+
"input": {
112+
"executor_a_first": true,
113+
"executor_b_second": true,
114+
"same_executor": false
115+
},
116+
"expected_one_of": [
117+
{
118+
"behavior": "raise",
119+
"error_type": "ContextBindingError"
120+
},
121+
{
122+
"behavior": "silent_accept",
123+
"documented_deviation_required": true
124+
}
125+
]
126+
},
127+
{
128+
"id": "child_propagates_executor",
129+
"description": "Context.child() MUST propagate the bound executor to the child Context.",
130+
"input": {
131+
"bind_executor": true,
132+
"create_child_module_id": "test.target"
133+
},
134+
"expected": {
135+
"child_executor_matches_parent": true,
136+
"child_caller_id_from_parent_chain_tip": true,
137+
"child_call_chain_appends_target": true
138+
}
139+
},
140+
{
141+
"id": "child_propagates_cancel_token",
142+
"description": "Context.child() MUST propagate the cancel_token to the child Context. Same reference (or equivalent linked instance) — modules deep in the call chain MUST observe cancellation.",
143+
"input": {
144+
"create_with_cancel_token": "TOKEN_FIXTURE_B",
145+
"create_child_module_id": "test.target"
146+
},
147+
"expected": {
148+
"child_cancel_token_bound": true,
149+
"child_cancel_token_matches_parent": true
150+
}
151+
},
152+
{
153+
"id": "deserialize_then_call_binds_local_executor",
154+
"description": "Cross-process scenario: deserialize a serialized Context (executor / cancel_token / services / global_deadline all stripped per §5.7), then call() on the receiving node. The local Executor MUST bind itself per the same rule as local Context.create().",
155+
"input": {
156+
"serialized_context": {
157+
"_context_version": 1,
158+
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
159+
"caller_id": "remote.caller",
160+
"call_chain": ["remote.caller", "remote.target"],
161+
"identity": {"id": "user-remote", "type": "user", "roles": [], "attrs": {}},
162+
"data": {}
163+
},
164+
"call_module": "local.target"
165+
},
166+
"expected": {
167+
"executor_after_deserialize": null,
168+
"cancel_token_after_deserialize": null,
169+
"services_after_deserialize": null,
170+
"global_deadline_after_deserialize": null,
171+
"caller_id_preserved": "remote.caller",
172+
"executor_bound_on_first_call": true
173+
}
174+
},
175+
{
176+
"id": "distributed_cancel_token_synthesized_locally",
177+
"description": "When a deserialized Context arrives on a remote node, the receiving Executor MUST synthesize a fresh local CancelToken at pipeline entry. Distributed cancellation does NOT ride the in-context cancel_token field.",
178+
"input": {
179+
"deserialized_context_has_no_cancel_token": true,
180+
"execute_module": "local.slow"
181+
},
182+
"expected": {
183+
"fresh_cancel_token_synthesized": true,
184+
"cancel_token_is_local_instance": true
185+
}
186+
},
187+
{
188+
"id": "distributed_global_deadline_recomputed_locally",
189+
"description": "When a deserialized Context arrives on a remote node, the receiving Executor MUST recompute global_deadline from local executor.global_timeout config. The originating node's deadline intent does not propagate via global_deadline.",
190+
"input": {
191+
"deserialized_context_has_no_global_deadline": true,
192+
"local_executor_global_timeout_ms": 60000
193+
},
194+
"expected": {
195+
"global_deadline_recomputed_locally": true,
196+
"global_deadline_derived_from_config": true
197+
}
198+
},
199+
{
200+
"id": "tracestate_carried_inside_traceparent",
201+
"description": "TraceParent type MUST carry tracestate as a field (no separate Context.create() parameter). Verifies cross-SDK shape: Python/TS/Rust all embed tracestate in TraceParent.",
202+
"input": {
203+
"trace_parent": {
204+
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
205+
"parent_id": "00f067aa0ba902b7",
206+
"trace_flags": "01",
207+
"tracestate": [["vendor1", "value1"], ["vendor2", "value2"]]
208+
}
209+
},
210+
"expected": {
211+
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
212+
"tracestate_preserved": true,
213+
"no_separate_tracestate_parameter": true
214+
}
215+
}
216+
]
217+
}

docs/features/context-object.md

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ For caller-identity semantics, type values, and ACL integration see [Identity Sy
1414
- Context MUST carry a `trace_id` that uniquely identifies the call chain and is preserved across all child invocations.
1515
- Context MUST carry the `caller_id` of the module that initiated the current call, or `None` for top-level calls.
1616
- Context MUST carry the `call_chain` (ordered list of module IDs from root to current invocation), maintained automatically by the Executor.
17-
- Context MUST carry an `executor` reference so modules can dispatch inter-module calls.
17+
- Context MUST carry an `executor` reference so modules can dispatch inter-module calls. The reference is **bound by the Executor** at pipeline entry (not by `Context.create()`); see [Executor binding to Context](./core-executor.md#contract-executor-binding-to-context).
1818
- Context SHOULD carry an `identity` describing the caller (used by ACL).
1919
- Context SHOULD expose a `logger` that auto-injects `trace_id`, `module_id`, and `caller_id`.
2020
- Context SHOULD expose `redacted_inputs` — the input dict with `x-sensitive` fields replaced by `"***REDACTED***"` — so middleware can log safely.
@@ -37,18 +37,19 @@ For caller-identity semantics, type values, and ACL integration see [Identity Sy
3737

3838
### Field Constraints
3939

40-
| Field | Type | Level | Limit | Thread Safety | Serializable |
41-
|-------|------|-------|-------|---------------|--------------|
42-
| `trace_id` | string (32-char hex) | MUST | 32 chars | Read-only, safe | MUST |
43-
| `caller_id` | string \| null | MUST | 128 chars | Read-only, safe | MUST |
44-
| `call_chain` | list[string] | MUST | Max depth 32 | Read-only, safe | MUST |
45-
| `executor` | Executor | MUST || Thread-safe | MUST NOT |
46-
| `identity` | Identity \| null | SHOULD || Read-only, safe | MUST |
47-
| `logger` | ContextLogger | SHOULD || Thread-safe | MUST NOT |
48-
| `redacted_inputs` | dict \| null | SHOULD || Read-only, safe | MAY |
49-
| `cancel_token` | CancelToken \| null | MAY || Thread-safe | MUST NOT |
50-
| `services` | T \| null | MAY || Read-only, safe | MUST NOT |
51-
| `data` | dict[str, Any] | MUST || Not thread-safe | SHOULD |
40+
| Field | Type | Level | Limit | Thread Safety | Serializable | Notes |
41+
|-------|------|-------|-------|---------------|--------------|-------|
42+
| `trace_id` | string (32-char hex) | MUST | 32 chars | Read-only, safe | MUST | Auto-generated. Not a `Context.create()` input. |
43+
| `caller_id` | string \| null | MUST | 128 chars | Read-only, safe | MUST | Top-level: null. Managed exclusively by `Context.child()`. Not a `Context.create()` input. |
44+
| `call_chain` | list[string] | MUST | Max depth 32 | Read-only, safe | MUST | Managed exclusively by the Executor. |
45+
| `executor` | Executor \| null | MUST (after binding) || Thread-safe | MUST NOT | **Bound by the Executor** at pipeline entry, not by `Context.create()`. See [Executor binding to Context](./core-executor.md#contract-executor-binding-to-context). |
46+
| `identity` | Identity \| null | SHOULD || Read-only, safe | MUST | |
47+
| `logger` | ContextLogger | SHOULD || Thread-safe | MUST NOT | Derived property from `trace_id` + `caller_id`. Not a `Context.create()` input. |
48+
| `redacted_inputs` | dict \| null | SHOULD || Read-only, safe | MAY | Populated by Executor pipeline step 5. |
49+
| `redacted_output` | dict \| null | SHOULD || Read-only, safe | MAY | Populated by Executor pipeline step 9. |
50+
| `cancel_token` | CancelToken \| null | MAY || Thread-safe | MUST NOT | First-class `Context.create()` parameter since v0.22.0. |
51+
| `services` | T \| null | MAY || Read-only, safe | MUST NOT | Caller-supplied DI container only — MUST NOT carry framework-owned fields. |
52+
| `data` | dict[str, Any] | MUST || Not thread-safe | SHOULD | |
5253

5354
### Call-Chain Safety
5455

@@ -154,7 +155,7 @@ For cross-process transfer (distributed execution, task queues) Context supports
154155
- Skipped (runtime-only) fields: `executor`, `cancel_token`, `services`.
155156
- A `_context_version: 1` field is included for forward compatibility.
156157

157-
After deserialization, the `executor` reference MUST be re-injected before the Context can be used for inter-module calls.
158+
After deserialization the `executor` field is null. The receiving Executor MUST bind itself on first `Executor.call()` per the unified rule in [Core Executor §Contract: Executor binding to Context](./core-executor.md#contract-executor-binding-to-context) — this covers local construction, cross-process deserialize, and hot-reload restore under a single mechanism. `cancel_token` and `services` are similarly runtime-only: the receiving Executor synthesizes a fresh local `CancelToken`; `services` is re-injected by the application boundary.
158159

159160
## Edge Cases
160161

@@ -487,7 +488,8 @@ The reserved `_apcore.` prefix described in [`data` Key Convention](#data-key-co
487488
client = APCore()
488489
user_identity = Identity(id="user-42", type="user", roles=["analyst"])
489490

490-
context = Context.create(executor=client.executor, identity=user_identity)
491+
# Executor self-binds on first call() — no need to pass executor= here.
492+
context = Context.create(identity=user_identity)
491493
context.data["task_info"] = {"type": "report", "date": "2024-01"}
492494

493495
client.executor.call("module_fetch", inputs={}, context=context)
@@ -509,7 +511,8 @@ The reserved `_apcore.` prefix described in [`data` Key Convention](#data-key-co
509511
const client = new APCore();
510512
const userIdentity = createIdentity('user-42', 'user', ['analyst']);
511513

512-
const context = Context.create(client.executor, userIdentity);
514+
// Executor self-binds on first call() — no need to pass it here.
515+
const context = Context.create(userIdentity);
513516
context.data['task_info'] = { type: 'report', date: '2024-01' };
514517

515518
await client.executor.call('module_fetch', {}, context);

0 commit comments

Comments
 (0)