Skip to content

Release 0.29.0 (2026-09-05)

Choose a tag to compare

@tercel tercel released this 05 Sep 05:05
· 93 commits to main since this release

Added

  • ApprovalRequest gains caller_id and action (spec v1.32.0, decision D-03). docs/features/approval-system.md's Contract block has required both since the Contract blocks were added; the 2026-05-02 alignment review recorded the same requirement as D-03, and neither field had reached docs/spec/protocol-spec.md §7.3.1's own YAML schema — the single source of truth for the type was the one place that had not caught up. caller_id is nullable (null on a top-level call, mirroring Context.caller_id); action is a flat duplicate of module_id, populated from the same construction site every SDK's BuiltinApprovalGate already has. Additive — no existing construction call breaks. New conformance fixture conformance/fixtures/approval_request_fields.json.
  • Canonical ## Contract: blocks added for public APIs that had none, found by a documentation-completeness audit. CancelToken.is_cancelled / .check / .reset (cancellation.md; .check was previously documented under the name raise_if_cancelled, which no SDK exposes — retitled to match the real API); ApprovalHandler.check_approval (approval-system.md); Executor.register_strategy, .list_strategies, .governance_state (core-executor.md); the Rust-only APCore.with_components / .with_options / .reload (apcore-client.md); the six read-only system.health.* / system.manifest.* / system.usage.* modules and OverridesStore.load / .save (system-modules.md); and TraceContext.inject / .extract / .from_traceparent, BatchSpanProcessor.on_end / .force_flush / .shutdown, ErrorHistory.record / .get / .get_all, UsageCollector.record / .get_summary / .get_module / .get_latencies, UsageExporter.export / .shutdown, and PeriodicUsageExporter.start / .stop (observability.md). Documentation-only; no SDK behaviour affected. core-executor.md's three sections headed ## Contract: but lacking Inputs/Errors/Returns/Properties (Executor binding to Context, Distributed cancellation, `global_deadline` distributed semantics) were brought to canonical shape — the first gained the missing structure (it has a real cross-boundary member, bind_executor/withExecutor, behind it); the other two, which describe cross-cutting rules rather than a single symbol, were retitled without the misleading Contract: prefix. middleware-system.md's StepMiddleware contracts were promoted from non-canonical ### Contract: / #### Inputs to ## Contract: / ### Inputs, matching every other contract in the file.

Fixed

  • docs/features/apcore-client.md's APCore.validate contract contradicted itself on a malformed module_id, and the half no SDK implements was the one readers would act on. The Errors block carried both "no errors are raised for validation failures" and "InvalidInputError(code=INVALID_MODULE_ID) — raised if module_id is empty or malformed (before pipeline begins)". The second contradicted the same contract's Returns block, which lists module_id as the first preflight check — a check that can only report a result if the malformed case reaches it instead of raising past it. apcore-python is unambiguous: Executor._validate_async catches the InvalidInputError from _validate_module_id and returns PreflightResult(valid=False, checks=[…]). The raise clause is removed, with a note recording the deliberate contrast against Executor.call, where the identical input does raise at the entry guard: call() executes and must refuse, validate() reports and must not.
  • The Rust adaptation table said module() was unavailable in Rust; apcore-rust has had APCore::module all along. The row read "N/A — Rust has no decorators; use impl Module + register()" — true about decorators, false about the method: client.rs exposes module(module_id, description, input_schema, output_schema, documentation, tags, version, metadata, examples, display, handler), building and registering a FunctionModule in one call as the Python/TypeScript helper does. Corrected, and impl Module + register() is now described as what it is: the route for a module needing more than a handler closure.
  • The same table did not record that Rust's APCore construction has no policy input. Python's APCore(policy=…) and TypeScript's new APCore({ policy }) accept an ExecutionPolicy; Rust's with_options does not. A genuine surface gap, but not a capability gap — both other SDKs ignore their own policy argument when the caller supplies an executor and direct them to wire it on that executor instead, which is exactly the route Rust callers already take via with_options(None, Some(executor), …). Documented as an adaptation rather than closed by widening the Rust constructor.
  • docs/features/module-interface.md documented Module.preview and Rust-only Module.as_streaming as one-line table rows with no canonical contracts. Both now have ## Contract: blocks. preview's states the constraint that is the entire point of the hook — it MUST NOT perform the side effects execute() would — which the table row only implied. as_streaming's states the Some/None consistency requirement against Module::stream() as a normative clause rather than a parenthetical.
  • docs/features/apcore-client.md's method table claimed APCore.reload() "re-discovers modules"; apcore-rust's own doc comment says the opposite ("Module re-discovery is not triggered; call APCore::discover explicitly after reload if you need fresh module discovery"). Corrected the table entry against the actual apcore-rust source and added ## Contract: APCore.reload (ModuleError(code=RELOAD_FAILED) when the client's Config has no backing file path; ModuleError(code=MODULE_RELOAD_CONFLICT) on a concurrent-mutation race), alongside new contracts for the other two Rust-only constructors, with_components and with_options. Documentation-only.
  • docs/features/observability.md's W3C Alignment Rules Rust example called TraceContext::inject(&context, Some(parent_id)) and TraceContext::inject_checked(&context, Some(parent_id)) — neither signature exists in apcore-rust. inject takes only context; the parent_id / trace_flags / tracestate overrides live on inject_with_options (and the validating inject_checked, which takes the same four arguments). Corrected the example to compile against the real API, and the new TraceContext.inject contract documents the resulting cross-language gap: Rust's inject / inject_with_options silently fall back to a random parent_id on a malformed override instead of raising, unlike Python/TypeScript, and TraceContext.from_traceparent has no Rust equivalent at all.
  • docs/spec/protocol-spec.md's own version header lagged its content. The frontmatter description still read v1.28.0 and the Version: / Last Updated: lines still read 1.30.0 / 2026-08-31, three releases behind the changelog table already recorded in the same file (up to 1.31.0, #112). Synced all three to 1.31.0 / 2026-09-04. Metadata-only; no normative text changed.
  • docs/features/apcore-client.md documented APCore.start / APCore.stop contracts that no SDK implements, contradicting decision D-09 (2026-05-02 alignment review, recorded in docs/spec/2026-05-decision-log.md), which found the two methods aspirational with no clients and called for their removal — already carried out in PROTOCOL_SPEC §12 but missed in this feature spec. Removed both ## Contract: sections and added a close() note to the Module Lifecycle requirements documenting it as a Python-only convenience (releases the cached sync event loop; idempotent; no TypeScript/Rust equivalent), per D-09's action item.
  • AsyncTaskManager.start_reaper's Contract required the call itself to be awaited, in all three SDKs, though none of them make it genuinely awaitable and none needs to (docs/spec/2026-09-decision-log.md E-01). Decision D-11 fixed the argument names, units, and ReaperHandle return type; it never decided start_reaper itself must be async, and no implementation has ever had an await inside it before returning the handle — starting a background sweep loop is a synchronous action in Python, TypeScript, and Rust alike. docs/features/async-tasks.md's signature table, Properties block, and code examples corrected: start_reaper returns the handle directly in every SDK; only ReaperHandle.stop() is genuinely awaitable. No SDK behaviour change — Python and Rust were already conforming to the corrected text; TypeScript's Promise<ReaperHandle> return (from this session's separate SDK dispatch) is documented as a compatibility surface, not elevated to a cross-language MUST.
  • APCore.discover's Properties line claimed "synchronous in all languages," contradicting both TypeScript and Rust (docs/spec/2026-09-decision-log.md E-02). TypeScript's default discovery path resolves each module's entry point via ESM dynamic import(), which has no synchronous form in Node; Rust's Registry::discover awaits a Discoverer trait that is async by its own definition, mirroring TypeScript's CustomDiscoverer and Python's ApprovalHandler as a pluggable, possibly-async extension point. docs/features/apcore-client.md corrected to state Python sync / TypeScript+Rust async, each with its structural reason, and to name the return-value/error/registry-state contract as what actually holds across languages. No SDK behaviour change — documentation-only.
  • apcore-rust's APCore.on / off could not report that the client has no event bus, so a subscription on a misconfigured client silently went nowhere. on() called get_or_insert_with on its event_emitter field, creating a standalone bus when sys_modules was disabled: the call returned a subscriber ID, and no framework event ever arrived, because the sys-modules and registry emitters are a different bus. off() mirrored it, returning a bare false that reads as "no such subscriber" when the real answer was "there is no bus". apcore-python and apcore-typescript both raise SysModulesDisabledError here, and this page's own Error Behavior table has required it throughout — apcore-rust's conformance suite even carried the two cases pre-written under #[ignore] attributes naming the gap. The Rust adaptation table is updated for the resulting signatures: on() now returns Result<String, ModuleError> and off() Result<bool, ModuleError>, the bool retained (Python/TypeScript return void) because "no such subscriber" and "events are off" are different answers and collapsing the first into the second is what made the defect invisible. This IS an SDK change in apcore-rust only, and a breaking one — see that repo's 0.29.0 BREAKING entry for the migration.
  • ACL(rules=[...])'s own construction door did not re-validate a rule mutated before it was ever passed in (spec v1.33.0, docs/spec/2026-09-decision-log.md E-03). apcore-typescript and apcore-rust already rejected the sequence "construct a well-formed rule, mutate it, then hand it straight to ACL(rules=[rule]) for the first time"; apcore-python silently accepted it, deferring safety to the §6.1.1 UNEVALUABLE backstop at check() time. ACL's own constructor is one of the three entry points §6.1.6 rule 3 names, and PROTOCOL_SPEC's own "assigned onto an already-constructed rule" language was ambiguous between "a rule already installed inside a live ACL and mutated afterward" (the genuinely uninterceptable backstop case) and "any rule object currently holding a bad value" — apcore-typescript and apcore-rust read it the first way, apcore-python the second. §6.1.4.1 and §6.2.1 are corrected to name the narrower, correct boundary; ACL.__init__ (apcore-python) now validates every rule it is handed, matching add_rule. New conformance driver case (_door_construct_mutated) added to test_acl_pattern_arity.py; the existing 9 installed_rule backstop cases are unaffected. This IS an SDK change in apcore-python only.

Changed

  • A pattern list with no operands made an ACL rule inert, and under default_effect: allow that permitted the call the rule named (spec v1.31.0, #112). callers / targets of [], ["$or"] or ["$not"] can never match; all three SDKs returned false from the matcher and validate_rules() reported nothing, so a deny rule an operator wrote, loaded and validated contributed nothing to the decision. Reached from a plain YAML file — ACL.load rejects an omitted callers / targets and permits an empty one.

    The array's shape is now closed at every entry point (§6.2.1): at least one element, every element a non-empty string, $or with at least one operand, $not with exactly one, and $or / $not nowhere but index 0 — rejected with ACLRuleError at file loading, direct construction and runtime insertion alike. schemas/acl-config.schema.json had declared minItems: 1 and minLength: 1 on both fields since the file existed, enforced by nothing: the same shape as #107 and #111, where the constraint was in the schema and no door enforced it, because no implementation validates an ACL file against the schema at load time.

    Three normative statements are replaced, not reinterpreted. §6.5's edge-case table required an empty list to make the rule "never match". §6.2.1 required ["$not"] to "evaluate to false (fail-closed)" — a label that predates §6.1.1 (v1.22.0) and is wrong, because a non-match is fail-closed on an allow rule and fail-open on a deny one. And ["$not", p1, p2, …] was implementation-defined: consult p1, drop the rest, which every SDK did, so targets: ["$not", "secrets.a", "secrets.b"] on an allow rule granted secrets.b — the second target the operator excluded.

    §6.2.1 also says for the first time that a pattern array is flat — the operators do not nest and there is no precedence, unlike the same two tokens inside conditions — and gains the worked examples it never had. A reserved token away from index 0 is now rejected, which makes the section's own long-unenforced "MUST NOT match a literal module ID equal to $or" hold by construction; measured beforehand, all three SDKs matched a module literally named $not.

    A second, validator-only tier reports arrays that are well-formed and still match nothing — ["$not", "*"] has legal arity, exactly one operand, and matches nothing, producing the identical fail-open. Those keep loading and change no decision. Stated as a criterion with a MUST-detect minimum rather than an enumeration, because the predicate cannot be closed without freezing the pattern language, and an incomplete predicate at a door would mean the same ACL file loads in one language and fails in another.

    Two orderings are pinned because implementing this in three SDKs produced two answers to each: add_rule MUST re-validate the rule it is handed, including one mutated after construction (a closed effect is never read again, a pattern array is); and validation order is effect → approval → callers / targets with rule index dominating all three, the pattern fields counting as one axis in which the §6.1.4.1 type fault precedes the shape closure and callers precedes targets. Three implementations produced three different axis orders, and one produced two different answers through two of its own doors because load validated rule by rule while direct construction swept axis by axis. §6.1.6 was cited for this ordering in an early draft and states none; §6.2.1 states it for the first time.

    BREAKING for any deployment carrying one of these shapes — which is exactly the population that believes it has a rule and does not. targets: [] meaning "everything" becomes ["*"]; meaning "nothing" means the rule should be deleted. The multi-operand $not is the one shape with no mechanical migration: ["$not", p1] preserves what the rule has actually been doing, but if NOT (p1 OR p2) was intended, a leading deny is not equivalent — a non-matching rule lets evaluation continue to later rules and a deny ends it — so that rewrite has to be done by hand against the rule's position. Migration tooling MUST NOT apply it automatically.

    New conformance fixture conformance/fixtures/acl_pattern_arity.json (41 cases). acl_evaluation.json drops empty_callers_matches_none and empty_targets_matches_none, which asserted the replaced reading.