Repository navigation
Release 0.29.0 (2026-09-05)
Added
ApprovalRequestgainscaller_idandaction(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 reacheddocs/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_idis nullable (null on a top-level call, mirroringContext.caller_id);actionis a flat duplicate ofmodule_id, populated from the same construction site every SDK'sBuiltinApprovalGatealready has. Additive — no existing construction call breaks. New conformance fixtureconformance/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;.checkwas previously documented under the nameraise_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-onlyAPCore.with_components/.with_options/.reload(apcore-client.md); the six read-onlysystem.health.*/system.manifest.*/system.usage.*modules andOverridesStore.load/.save(system-modules.md); andTraceContext.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, andPeriodicUsageExporter.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 misleadingContract:prefix.middleware-system.md'sStepMiddlewarecontracts were promoted from non-canonical### Contract:/#### Inputsto## Contract:/### Inputs, matching every other contract in the file.
Fixed
docs/features/apcore-client.md'sAPCore.validatecontract contradicted itself on a malformedmodule_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 ifmodule_idis empty or malformed (before pipeline begins)". The second contradicted the same contract's Returns block, which listsmodule_idas 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_asynccatches theInvalidInputErrorfrom_validate_module_idand returnsPreflightResult(valid=False, checks=[…]). The raise clause is removed, with a note recording the deliberate contrast againstExecutor.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 hadAPCore::moduleall along. The row read "N/A — Rust has no decorators; useimpl Module+register()" — true about decorators, false about the method:client.rsexposesmodule(module_id, description, input_schema, output_schema, documentation, tags, version, metadata, examples, display, handler), building and registering aFunctionModulein one call as the Python/TypeScript helper does. Corrected, andimpl 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
APCoreconstruction has nopolicyinput. Python'sAPCore(policy=…)and TypeScript'snew APCore({ policy })accept anExecutionPolicy; Rust'swith_optionsdoes not. A genuine surface gap, but not a capability gap — both other SDKs ignore their ownpolicyargument 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 viawith_options(None, Some(executor), …). Documented as an adaptation rather than closed by widening the Rust constructor. docs/features/module-interface.mddocumentedModule.previewand Rust-onlyModule.as_streamingas 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 effectsexecute()would — which the table row only implied.as_streaming's states theSome/Noneconsistency requirement againstModule::stream()as a normative clause rather than a parenthetical.docs/features/apcore-client.md's method table claimedAPCore.reload()"re-discovers modules"; apcore-rust's own doc comment says the opposite ("Module re-discovery is not triggered; callAPCore::discoverexplicitly afterreloadif you need fresh module discovery"). Corrected the table entry against the actualapcore-rustsource and added## Contract: APCore.reload(ModuleError(code=RELOAD_FAILED)when the client'sConfighas 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_componentsandwith_options. Documentation-only.docs/features/observability.md's W3C Alignment Rules Rust example calledTraceContext::inject(&context, Some(parent_id))andTraceContext::inject_checked(&context, Some(parent_id))— neither signature exists inapcore-rust.injecttakes onlycontext; theparent_id/trace_flags/tracestateoverrides live oninject_with_options(and the validatinginject_checked, which takes the same four arguments). Corrected the example to compile against the real API, and the newTraceContext.injectcontract documents the resulting cross-language gap: Rust'sinject/inject_with_optionssilently fall back to a randomparent_idon a malformed override instead of raising, unlike Python/TypeScript, andTraceContext.from_traceparenthas no Rust equivalent at all.docs/spec/protocol-spec.md's own version header lagged its content. The frontmatterdescriptionstill readv1.28.0and theVersion:/Last Updated:lines still read1.30.0/2026-08-31, three releases behind the changelog table already recorded in the same file (up to1.31.0, #112). Synced all three to1.31.0/2026-09-04. Metadata-only; no normative text changed.docs/features/apcore-client.mddocumentedAPCore.start/APCore.stopcontracts that no SDK implements, contradicting decision D-09 (2026-05-02 alignment review, recorded indocs/spec/2026-05-decision-log.md), which found the two methods aspirational with no clients and called for their removal — already carried out inPROTOCOL_SPEC§12 but missed in this feature spec. Removed both## Contract:sections and added aclose()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.mdE-01). Decision D-11 fixed the argument names, units, andReaperHandlereturn type; it never decidedstart_reaperitself must be async, and no implementation has ever had anawaitinside 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_reaperreturns the handle directly in every SDK; onlyReaperHandle.stop()is genuinely awaitable. No SDK behaviour change — Python and Rust were already conforming to the corrected text; TypeScript'sPromise<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.mdE-02). TypeScript's default discovery path resolves each module's entry point via ESM dynamicimport(), which has no synchronous form in Node; Rust'sRegistry::discoverawaits aDiscoverertrait that isasyncby its own definition, mirroring TypeScript'sCustomDiscovererand Python'sApprovalHandleras a pluggable, possibly-async extension point.docs/features/apcore-client.mdcorrected 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/offcould not report that the client has no event bus, so a subscription on a misconfigured client silently went nowhere.on()calledget_or_insert_withon itsevent_emitterfield, creating a standalone bus whensys_moduleswas 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 barefalsethat reads as "no such subscriber" when the real answer was "there is no bus". apcore-python and apcore-typescript both raiseSysModulesDisabledErrorhere, 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 returnsResult<String, ModuleError>andoff()Result<bool, ModuleError>, theboolretained (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's0.29.0BREAKING 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.mdE-03). apcore-typescript and apcore-rust already rejected the sequence "construct a well-formed rule, mutate it, then hand it straight toACL(rules=[rule])for the first time"; apcore-python silently accepted it, deferring safety to the §6.1.1 UNEVALUABLE backstop atcheck()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, matchingadd_rule. New conformance driver case (_door_construct_mutated) added totest_acl_pattern_arity.py; the existing 9installed_rulebackstop 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: allowthat permitted the call the rule named (spec v1.31.0, #112).callers/targetsof[],["$or"]or["$not"]can never match; all three SDKs returnedfalsefrom the matcher andvalidate_rules()reported nothing, so adenyrule an operator wrote, loaded and validated contributed nothing to the decision. Reached from a plain YAML file —ACL.loadrejects an omittedcallers/targetsand 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,
$orwith at least one operand,$notwith exactly one, and$or/$notnowhere but index 0 — rejected withACLRuleErrorat file loading, direct construction and runtime insertion alike.schemas/acl-config.schema.jsonhad declaredminItems: 1andminLength: 1on 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 anallowrule and fail-open on adenyone. And["$not", p1, p2, …]was implementation-defined: consultp1, drop the rest, which every SDK did, sotargets: ["$not", "secrets.a", "secrets.b"]on anallowrule grantedsecrets.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_ruleMUST re-validate the rule it is handed, including one mutated after construction (a closedeffectis never read again, a pattern array is); and validation order iseffect→approval→callers/targetswith 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 andcallersprecedestargets. Three implementations produced three different axis orders, and one produced two different answers through two of its own doors becauseloadvalidated 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$notis the one shape with no mechanical migration:["$not", p1]preserves what the rule has actually been doing, but ifNOT (p1 OR p2)was intended, a leadingdenyis not equivalent — a non-matching rule lets evaluation continue to later rules and adenyends 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.jsondropsempty_callers_matches_noneandempty_targets_matches_none, which asserted the replaced reading.