Skip to content

Latest commit

 

History

History
1502 lines (1011 loc) · 277 KB

File metadata and controls

1502 lines (1011 loc) · 277 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.


[0.30.0] - 2026-09-06

Ships PROTOCOL_SPEC v1.34.0 → v1.36.0. The three spec versions are one release: v1.34.0 declared the path-typed key set, v1.35.0 built the binding-discovery and path-resolution contracts on it, and v1.36.0 repaired what writing the first conformance drivers for those contracts exposed. Each spec version keeps its own row in §13's revision table.

Added

  • PROTOCOL_SPEC §9.2.1 Path-Typed Configuration Keys (new, spec v1.34.0) — the specification never said which configuration values are filesystem paths, so every consumer that needed to know maintained its own list (#113). §9.1.1 gives four keys a relative default (extensions.root, schema.root, acl.root, bindings.dir) and §9.2 makes each of them environment-overridable, but nothing marked them as paths. That answer matters outside this repository: a consumer forwarding apcore configuration across a process boundary — a CLI spawning a worker, a supervisor building a container environment — has to know which APCORE_* variables carry paths, because a relative value silently re-roots wherever the working directory differs. apcore-cli had already hand-maintained exactly such a list (SANDBOX_PATH_TYPED_VARS), carrying a note that adding a path-typed key means editing three SDKs; nothing could detect drift between that list and this repository. The set is now closed and declared where the key surface is already defined: schemas/apcore-config.schema.json and schemas/defaults.schema.json carry "x-apcore-path": true on each path-valued property, implementations MUST expose the set through a public accessor, and a path-valued key added to §9.1 MUST carry the marker in the same change. Two exclusions are stated rather than left to inference, because both are the mistakes an implementer would otherwise make: bindings.pattern is a glob matched against filenames within bindings.dir and is never resolved as a path itself, and extensions.roots is list-valued with no scalar environment encoding — an implementation MUST NOT invent a delimiter-separated APCORE_EXTENSIONS_ROOTS. Purely additive: no key changes meaning, no default moves, and no SDK behaviour is required to change beyond exposing the accessor. The resolution base is deliberately not decided here. acl.root resolves against the config file's directory (ACL.discover, D-64) while schema.root resolves against the process CWD (SchemaLoader) — a live divergence between two sibling keys, tracked in #113, whose repair is a separate decision between a CWD-for-everything rule (MINOR) and origin-tracked resolution (MAJOR, with the two-minor deprecation §13.2 requires). Declaring the set is the prerequisite both options share, which is why it lands first and alone. New conformance fixture conformance/fixtures/config_path_typed_keys.json (7 cases), whose discriminating case is bindings.pattern: an implementation that classifies path-typed keys by config section, or by "the default looks like a filename", passes every presence-only assertion and fails that one.

  • §9.2.1 requirement 5 — an empty string is not a path. §9.2 treats a set but empty APCORE_* variable as an override, so export APCORE_ACL_ROOT= silently blanks a directory the configuration file correctly declared, and the resulting "" then resolves to the working directory. The same shape is already on record for APCORE_CONFIG_FILE (#88), where an empty value injected a phantom config.file key; path-typed keys are the population where it fails silently rather than loudly, because "" is a legal relative path to every filesystem API and never the one an operator meant. An empty path-typed value MUST be discarded and resolution MUST fall through to the next tier. Found by the TypeScript conformance work, which hit it on a real test run.

  • §9.2.2 fixes the deprecation warning's cadence: once per configuration load, never once per process. v1.35.0 required the warning and stated its two narrowing conditions but said nothing about cadence — and the three SDKs immediately invented three different ones (Python deduplicating through the warnings filter, TypeScript holding a module-global once-flag, Rust warning per load). A process-global flag makes emission order-dependent: whichever configuration loads first consumes the warning, so a later affected document is silent and the operator cannot tell which one triggered it; it is also a test-isolation hazard, which is how the divergence arose. De-duplication for log volume belongs to the host logging layer. This is the same "specification is silent, so each implementation invents an answer" pattern the §9.2.x work exists to close, caught inside the change that closes it.

  • schemas/defaults.schema.json gains the bindings section it never had. bindings.dir (./bindings) and bindings.pattern (*.binding.yaml) were declared in §9.1.1 and in apcore-config.schema.json's BindingsConfig, but absent from the file that describes itself as the "Single source of truth for all configuration default values across apcore SDK implementations. SDKs MUST use these values as fallbacks." Because conformance/fixtures/config_key_governance.json pins each SDK's default table to that schema, no SDK could carry the default — all three had to hardcode ./bindings at the loader instead, which is the same declared-in-one-place-unreachable-from-the-mechanism defect as the rest of this batch. bindings.dir carries x-apcore-path: true like its three sibling roots. config_key_governance.json regenerated: 65 allowed keys, 20 canonical defaults. This requires a matching change in all three SDK default tables, or the governance fixture fails.

  • PROTOCOL_SPEC §9.2.2 Path Resolution Base (new, spec v1.35.0) — §9.1.1 gives four keys a relative default and nothing said what they are relative to (#113). v1.34.0 closed the question of which configuration values are paths and deliberately left open what a relative one resolves against. That base is not merely undocumented — it is two different bases in the shipped SDKs: acl.root resolves against the configuration file's directory (ACL.discover, decision D-64, docs/features/acl-system.md) while schema.root and extensions.root resolve against the process CWD, unconditionally. Two sibling keys, identical relative values, identical APCORE_* override syntax, different answers, and neither behaviour violated anything, because the specification had no opinion. The project root is declared. Every Config has exactly one, fixed at load time: the directory containing the configuration file when that file was selected by §9.14 discovery tiers 1-5 ($APCORE_CONFIG_FILE, or a project-local ./project.yaml / .yml / ./apcore.yaml / .yml), and the process CWD when it came from the user-level tiers 6-7 (~/.config/apcore/config.yaml, ~/.apcore/config.yaml), when discovery found nothing, or when the Config has no backing file. The discovery tier is what selects the base, and it has to be: in tiers 2-5 the file's directory is CWD and every candidate rule agrees, so that population — the overwhelming majority — is unaffected either way; tier 1 is where a configuration file can sit outside CWD, and there its own directory is the better reading of a path written beside it; tiers 6-7 are where the file's directory is wrong, because extensions.root: ./extensions in ~/.config/apcore/config.yaml cannot mean ~/.config/apcore/extensions. A user-level configuration's relative paths are per-project by intent. The user-level tier is where today's behaviour is actively wrong, and it is acl.root — the security key — that has it. A config at ~/.config/apcore/config.yaml carrying acl.root: ./acl loads its policy from ~/.config/apcore/acl/ into every project that user runs, while the project's own ./acl/ is ignored: for a default-deny, explicitly-granted authorization system that is the inverse of the intent. The same load resolves extensions.root against CWD, so one configuration document produces two bases in a single load — reproduced, not hypothesised, with the §9.3 semantic check firing about the project's missing ./extensions in the same run that silently loaded a foreign ACL. Target semantics (v2.0). Every relative path-typed value — §9.2.1's closed set — resolves against the project root: file-declared, environment-sourced, API-supplied and the §9.1.1 defaults alike. Three properties are stated as requirements rather than left to implementers: one base per Config (an implementation MUST NOT resolve two path-typed keys of the same Config differently), no per-key origin tracking (nothing has to record which precedence tier produced a value — the largest implementation cost across three SDKs, removed by construction), and defaults have the same home as everything else ("./schemas" resolves like a written value). APCORE_ACL_ROOT=./x set for a project run resolving against the project root is also the better reading of intent: an operator setting it means "relative to this project". This release changes no behaviour. It is the deprecation phase, which §13.2's "keep at least 2 minor versions for deprecation period" makes the floor for a change to deployed configurations. The 1.x line keeps the current semantics exactly, and adopting the target rule early is forbidden by requirement 3. What is required now is additive: implementations MUST expose the project root through a public accessor (Config.project_root / projectRoot() / Config::project_root()), carrying no resolution behaviour, so an application, a CLI or a conformance driver can ask what the base will be before anything depends on it. Implementations SHOULD warn — but only when the project root differs from CWD and at least one path-typed value is relative. A blanket warning is explicitly rejected: it would fire for every project in tiers 2-5, where nothing changes, which trains operators to ignore the one warning that matters. Migration, by tier: tiers 2-5 — none, project_root already equals CWD. Tiers 6-7 — acl.root moves from the user-level directory to CWD, which is a bug fix, not a regression; the other three keys already resolve against CWD and do not move. Tier 1 with a config outside CWD — the one genuine break, where schema.root, extensions.root and bindings.dir move from CWD to the configuration file's directory. That is precisely the population the narrow warning is scoped to reach. Side effect on a Proposed RFC. docs/spec/rfc-config-include.md open question #1 — whether a path value declared inside an included fragment resolves relative to that fragment or to the root file — is settled by adoption: one base for the whole Config leaves the fragment-relative reading no room, since it would reintroduce exactly the per-value origin tracking §9.2.2 forbids. That RFC's Status remains Proposed; this section constrains the answer it may give and does not adopt it. New conformance fixture conformance/fixtures/config_project_root.json (14 cases), carrying one case per §9.14 tier because the tier is the input under test, plus the three cases that pin the warning condition from both sides (fires when root ≠ CWD and a relative value is present; silent when the roots coincide; silent when every path value is absolute). v1x_current_bases_unchanged pins the deprecation phase itself: under a tier-1 config outside CWD, acl.root still resolves file-relative and schema.root still resolves against CWD, with acl/ and schemas/ present under both directories so exactly one semantics passes — an SDK that ships the v2.0 rule ahead of its window fails there. docs/features/acl-system.md's ACL.discover Side Effects step 2 is annotated as current-behaviour-superseded-at-2.0; its described behaviour is unchanged and remains what implementations must ship for the whole 1.x line.

Changed

  • Fixture repairs, all found by writing the first conformance drivers for v1.35.0's own fixtures. These fixtures were authored from the specification text and had never been executed by any implementation; the drivers were the first thing to run them, and three SDKs reported the same defects independently. bindings_dir_resolution gains env_var_must_not_be_read_directly_at_the_loader — §5.12.6 clause 2 had no case at all, so an implementation reading the raw APCORE_BINDINGS_DIR (precisely the apcore-typescript#36 defect the clause exists to forbid) passed the entire fixture; the new case sets the variable after the Config is built, which separates a loader reading the merged configuration from one reading the environment. Its candidate directories now carry distinct module ids: with one shared id, env_overrides_config_file_dir reported ["greet"] whichever directory was scanned, so the case that exists to pin env-over-file precedence could not detect an implementation ignoring the env tier. config_project_root's tier-6 and tier-7 cases now name the tier through <tier6_config> / <tier7_config> tokens instead of hardcoding fakehome/.config/apcore — §9.14 states tier 6 is platform-varying (XDG on Linux, ~/Library/Application Support on macOS), so the literal spelling made every driver fail on macOS while asserting nothing extra on Linux. no_warning_when_all_path_values_absolute now spells every §9.2.1 key absolutely; as first published it set only schema.root and acl.root, leaving extensions.root and bindings.dir at their relative §9.1.1 defaults, which §9.2.2 counts — so the case was unsatisfiable against the very rule it was testing, and all three SDKs reported it as such.

  • PROTOCOL_SPEC §5.12.6's binding-discovery MUST had no subject and no trigger, and no SDK satisfied it (spec v1.35.0, #114). The requirement read "if bindings.dir is configured, implementations MUST scan files matching pattern in that directory". It never said who scans or when, and that absence is why both obvious readings felt wrong — each was a different guess at the missing subject. Measured rather than assumed: bindings.dir is registered in all three key surfaces (apcore-python config.py:217, apcore-typescript config-key-surface.ts:70, apcore-rust config.rs:213) and read by no code path, while BindingLoader is exported public API in all three (__init__.py:198, index.ts:277, lib.rs:66) and called from no internal one. That is a design, not an oversight: binding loading is a user-invoked tool. A user who set bindings.dir in apcore.yaml got no scan in any SDK; a user who exported APCORE_BINDINGS_DIR got one in TypeScript only, which had implemented the environment tier alone of the key's precedence chain via a raw process.env read at bindings.ts:163. The requirement now names its subject and its trigger. A binding loader invoked without an explicit directory argument MUST resolve the scan directory from bindings.dir under §9.2 precedence — APCORE_BINDINGS_DIR > configuration file > default "./bindings" — and MUST match candidate files against bindings.pattern through the same chain, default "*.binding.yaml" (the pattern moves out of the loader signature, where it had been living as a hard-coded default). An explicit directory argument still wins: explicit > env > file > default. And an implementation MUST NOT read APCORE_BINDINGS_DIR directly at the loader — the environment tier arrives through the ordinary APCORE_* override mechanism, so one precedence chain governs the key and TypeScript's existing users keep working. Auto-scanning at initialisation is now forbidden explicitly, because it is the reading the old wording invited: implementations MUST NOT scan a binding directory as part of client or framework initialisation. It would add filesystem I/O to every client's startup and change behaviour for every deployment that merely happens to have a ./bindings directory. Nothing has asked for it. The MUST is not weakened — a requirement no implementation could satisfy, and whose subject could not be identified from its own text, is made enforceable and testable for the first time. Nor is this a re-run of the bindings.files withdrawal recorded in the same section: that key was schema-invalid (BindingsConfig is additionalProperties: false over {dir, pattern}), absent from every key surface, and implemented nowhere, so there was nothing to wire. bindings.dir is declared by the canonical schema with a default, present in all three key surfaces and in config_key_governance.json, and already half-implemented in TypeScript — withdrawing it would mean a §13.2 deprecation cycle across the schema, three key surfaces and the governance fixture, more churn than the fix, and it would break existing TypeScript users. That note is retained unchanged. New conformance fixture conformance/fixtures/bindings_dir_resolution.json (8 cases). Its discriminating case is config_file_dir_is_scanned_with_env_unsetbindings.dir in a config file, APCORE_BINDINGS_DIR unset, loader called with the directory argument genuinely absent — because every existing loader test in all three SDKs passes an explicit directory, which is the one path that works identically under both the old and the corrected behaviour. no_auto_scan_at_init pins the prohibition from the other side by placing a well-formed binding file under the configured directory and asserting its module ID never reaches the registry.

Fixed

  • PROTOCOL_SPEC §5.12.2 declared the binding field target_id a MUST; the canonical schema, the companion spec, both binding fixtures and all three SDKs use target (#115). The protocol specification was the sole outlier, and it was the outlier on a MUST, in the section whose entire purpose is to define the binding-file format — so a binding file written from the spec loaded in no SDK. Corrected throughout §5.12, and in the ten further occurrences outside it (§5.13.9's ResolvedModule type, §8.2 / §8.6 / §8.7 error descriptions, §5.14.6 and §9.15 prose) where a past over-applied targettarget_id rename had left the wrong spelling standing, including in ordinary English ("Deploy application to target_id environment"). The 16 remaining target_id occurrences are ACL caller_id / target_id and schema $ref contexts, which are correct. No implementation changes and no deployed file is affected: the population of binding files using target_id is empty, because such a file has never loaded. conformance/fixtures/bindings_dir_resolution.json corrected to match.

  • §5.12.6 never said what happens when the resolved binding directory does not exist, and the fixture guessed the opposite of every implementation. v1.35.0 gave that section's MUST a subject and a trigger but left its failure mode unstated. New clause 5: a resolved directory that does not exist MUST raise, naming the resolved directory, and MUST NOT return an empty result — which is what apcore-python, apcore-typescript and apcore-rust all already do, each with tests pinning it. The contrast with ACL.discover's missing-path no-op (D-64) is now recorded as deliberate rather than accidental: ACL discovery is automatic, so a missing acl.root must be silent; binding loading is user-invoked, so an absent directory is a mistake, and returning zero modules silently reproduces the "configuration key that quietly does nothing" defect §5.12.6 was rewritten to remove.


[0.29.0] - 2026-09-05

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 effectapprovalcallers / 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.


[0.28.0] - 2026-08-31

Added

  • The ACL could refuse on a call's arguments, and could not ask about them (spec v1.28.0, #108). git push and git push --force are one module, and the only way to gate the dangerous half was to mark the whole module requires_approval and stand down inside the handler — which floods the audit trail with approvals for calls nobody needed to see and weakens the annotation from "this needs approval" to "this might". Every decision point that can read a call's arguments was unable to escalate it to a human, and the one point that decides whether to ask a human (§7.9's policy resolution) is forbidden by §7.9.6 rule 2 from consulting them.

    §6.1.6 — authorization and approval are two results, not one. A rule keeps effect and gains an orthogonal optional approval: required | not_required. Absence means not_required, so every rule written before this section keeps its meaning exactly. approval: required on a deny rule is rejected at every entry point that accepts a rule — file loading, direct construction and runtime insertion: "denied and needs approval" is not a state that means anything, and a deny rule never reaches the approval gate, so a requirement attached to one can only mislead the operator who wrote it. add_rule returns nothing by its own contract in all three SDKs, which is not an exemption — an implementation provides a fallible variant beside it or fails loudly. Adding the field was only safe once §6.1.5 closed the rule key set in v1.27.0; an SDK that still dropped unknown keys would read a deny-with-approval rule as a bare rule and act on half of what the operator wrote.

    §6.1.7 — the built-in arguments condition, with three structure-only predicates: has_key, has_all_keys, has_none_of. No predicate reads a value, and that is a constraint rather than a first cut. The argument view at Step 4 is not reliably redacted — redaction is driven by x-sensitive markers in the module's input schema, and a module without one gets no field redaction at all — so a value-reading predicate would pull secret-bearing data into the governance decision path and into handler_error diagnostics. The arguments are also unvalidated at that point, because the ACL check is Step 4 and input schema validation is Step 7. Key presence is the one question well-defined on unvalidated input, and it answers the driving requirement: "did this call carry --force?" It is built-in and needs no registration, because a deployment-registered argument handler would be exactly the unauditable host code §7.9.6 rule 2 keeps out of a governance verdict.

    §6.1.8 — the governance projection. The condition reads a projection carrying the argument key set and optionally each key's JSON type, never a value: a projection that structurally cannot hold a value cannot leak one, whatever a future predicate does with it. It is computed by the framework at Step 3 and MUST NOT be accepted from caller-supplied input — a caller that could supply its own could satisfy has_none_of for a call whose arguments say otherwise, turning the condition into a caller-controlled switch. Substituting redacted_inputs is forbidden: its contract is safe logging, and one field serving both that and "input to a security decision" will eventually break one of them in a change made for the other. Four shapes resolve to UNEVALUABLE rather than to a boolean — no projection available, an empty predicate object, an unrecognised predicate name, a malformed predicate value — because each is a case where "false" is safe on a deny rule and fails open on an allow rule. The first is the sharp one: has_none_of over an empty stand-in is satisfied, so an allow rule would grant for a call whose arguments were never seen.

    §6.8.1 and §6.3.1. The structured accessor returns access and the approval requirement separately; the legacy boolean fails closed on an approval requirement, because a boolean can only be read as "let it through" and letting it through would run a call the ACL said needed a human. AuditEntry gains approval_required beside decision rather than widening it — decision is a string downstream consumers parse, and a third value would break every existing parser.

    Landed with conformance/fixtures/acl_argument_scoped_approval.json (20 cases) and drivers in all three SDKs. Writing the fixture is what settled the last two questions: an arguments fault's condition path descends to arguments.<predicate>, and every faulty predicate in one block is reported rather than the first — apcore-rust already did both, and the divergence between the other two was measured, not hypothesised.

Changed

  • Four corrections to v1.22.0–v1.24.0, found by implementing them in all three SDKs (spec v1.25.0, #100 / #102). The first round of SDK work was run as three independent implementations against one spec text, and the places where they diverged are where the text was underspecified.

    §6.1.1's closed list of three unevaluable situations was wrong, in the direction the section exists to prevent. All three SDKs classified a malformed compound value ($or: "not-a-list", $not: 3) as UNSATISFIED, and all three independently flagged the choice as suspect — a handler handed a malformed value does run to completion, so the closed list left no other reading. The result was a deny rule carrying $or: "typo" staying inert: the v1.22.0 defect, through a door v1.22.0 left open. A non-mapping conditions produced a three-way split on identical input — apcore-python raised AttributeError out of check() (violating its own contract that check MUST NOT raise), apcore-typescript denied, apcore-rust went inert. "Unevaluable" is now a principle — the implementation cannot answer the condition as written — with five non-exhaustive examples, and an implementation meeting an unlisted case MUST classify it by the principle rather than defaulting to UNSATISFIED.

    §6.1.4 (new) — a context-independent structural and registry precheck, which settles two questions that were pulling against each other. §6.1.1 rule 2 wanted deterministic handler_error while the composition rules permitted short-circuiting; and §6.5 kept "no context supplied" a non-match, which let a misspelled key on a context-less call escape §6.1.1 entirely — verified in all three SDKs, all three returned "allowed". The precheck walks the whole rule structure without a context and without running a handler, and runs before §6.5's context check. A rule that passes the precheck and then finds no context still takes §6.5's path, so a registered, context-dependent condition such as roles is not unevaluable merely because this caller sent no identity — §6.5's design is intact and only the malformed case changes. Because the precheck is exhaustive and handler-free its findings are a pure function of the rule, so precheck-origin diagnostics are identical across implementations while execution-origin ones may still vary with short-circuiting. That is stated explicitly rather than left to a SHOULD NOT that pinned neither behaviour.

    Condition paths. handler_error and the validator now order by path ($or[1].$not.k) rather than by key, because a nested $or may carry one key at several positions, which leaves ordering by key undefined.

    §6.1.3 — sync_registered / async_registered renamed sync_resolvable / async_resolvable. They always meant "resolvable on that evaluation path", and since async_check() falls back to the sync registry, async_resolvable is the union of both — so the old name read as a registry lookup and would be false for every built-in leaf handler, which resolves on both paths.

    §7.9.6 — rule 3(b) withdrawn. It promised that "a host-supplied policy implementation can decide on arguments". ExecutionPolicy is a concrete class in apcore-python and apcore-typescript and a concrete struct in apcore-rust, and set_policy takes that concrete type in all three, so no host can supply one; the clause described a capability that does not exist. Making it pluggable is deliberately not specified — no requirement has asked for it. Rule 5 (now 7) is restated as a capability requirement rather than an API shape after the three SDKs produced three reasonable shapes (an added method, keyword-only parameters, an options object); and _approval_token must now be stripped before policy resolution, which §7.4's existing "before passing to subsequent steps" does not reach, since resolution happens inside Step 5.

    §6.8 clarifies rather than relaxes: an accessor MAY acquire the ACL lock internally, copy, and release before returning — what is forbidden is returning a value whose validity depends on a lock the caller must release. §6.3.1 records that handler_error is per-check() while matched_rule_index is per-rule, so one audit entry may legitimately describe two different rules.

Fixed

  • A rule's effect accepted any string outside the YAML loader, and was silently read as deny (spec v1.30.0, #111). §6.1's field table says effect MUST be allow | deny and schemas/acl-config.schema.json declares the enum, but nothing enforced it away from the file path. This is #107 one level down: there the rule key set was closed while an unknown key was dropped in silence; here the key is legal and its value is dropped, in the same silence.

    Measured on effect: "Allow" — a capitalisation an operator writes by hand. apcore-python and apcore-typescript rejected it from ACL.load() and accepted it through direct construction and add_rule(). apcore-rust rejected it at load and at construction and accepted it at add_rule(), whose validation covered §6.1.6's deny + approval combination and nothing else. All three had a hole and merely had different ones — which is why the closure is specified per entry point rather than per implementation. All three emit the identical loader message "Rule 0 has invalid effect 'Allow', must be 'allow' or 'deny'", so the check existed everywhere and was simply not reached from every door.

    The inconsistency was internal too. All three validate default_effect — the same two legal values one field up — at every door that accepts one. So the loader guarded one door, default_effect guarded all of them, and a rule's effect guarded only the file path. §6.1.6 rule 3 already requires rejection at every entry point that accepts a rule, and had never been applied to the field it is named after.

    Not a privilege escalation, because no unknown value grants. It is a silent functional break in two distinct shapes. Where the value is normalised toward deny, a rule the operator wrote to permit denies everything it matches under default_effect: allow, with no error, no warning, and nothing from validate_rules(); on a deny rule that reading is only accidentally right, true until someone revisits which way it points. Where the value is not inspected at all — apcore-rust — the raw string reached AccessDecision.access and the audit entry's decision, so effect: "Allow" produced access: "Allow", a verdict string no consumer parses, and the same literal comparison silently dropped any approval: required the rule carried, re-entering #109's defect class through a typo in a different field.

    §6.1.5 now states the effect value closure and the entry-point requirement, with default_effect stated on the same terms rather than left correct-by-convention. The rejection names the offending value, and the rule index only where the entry point has one — a rule under construction has no position yet, which the apcore-python and apcore-typescript implementations reported independently. Closing the doors is the mechanism: once every entry point rejects, the decision read becomes total over the closed set rather than a fallback with a default arm, and no evaluation-time branch for an unrecognised value is reachable through any API the specification defines. An implementation MUST NOT resolve an unrecognised effect to a decision, nor pass it through as one. This IS an SDK change in all three.

  • requires_approval: false was documented as "no consent needed", which v1.28.0 made untrue (spec v1.29.0, #110). The JSON Schemas said "true = AI must ask for user consent before calling", §3's table said "Whether requires human approval before execution", and §3's AI reading guide listed only the true case. Read literally, false told a client no consent was needed. Since v1.28.0 the annotation is one source among several and §6.9 rows 3–5 compose them by union, so an ACL rule carrying approval: required, an ExecutionPolicy override, or gate_destructive can require approval for a particular call on a module whose annotation says false.

    Nothing was unsafe: the approval gate still fails closed and asks the human at execution time. What was lost is the client's ability to say in advance that a call will need approval — which is the whole reason the annotation is read before calling. Demonstrated against apcore-python: a module declaring requires_approval=False with the driving §6.1.7 ACL shape reports validate(..., {remote, force: True}).requires_approval == True and validate(..., {remote}).requires_approval == False, while the static annotation stays False in both.

    The annotation describes the module; validate() (§7.9.5) describes the call. Both JSON Schemas, §3's annotation table, §3's reading guide and docs/features/module-interface.md now say so.

    Deliberately NOT specified: a conditional tri-state. The module author cannot know the answer — it depends on the ACL the deployment loads and the policy it configures, neither visible when the annotation is written. A third value would have to be computed by the framework from the loaded governance config rather than declared, which is validate() with extra steps and a breaking change to a field every consumer reads as a boolean.

    No behaviour change and no SDK change.

  • An unevaluable approval rule stepped aside and the call was granted without approval (spec v1.29.0, #109). The shape §6.1.7 was written for is a narrow approval rule ahead of a broad allow — git push --force needs a human, git push does not. When the narrow rule's condition could not be evaluated, §6.1.1 resolved it to "does not match, MUST NOT grant" and scanning continued; the broad rule then granted, carrying no requirement of its own. The result was allow with approval_required: false on exactly the call the operator gated, with matched_rule_index naming a rule that never mentioned approval. Reproduced in all three SDKs.

    The root cause is a section that outlived its assumptions. §6.1.1 was written in v1.22.0, when a rule carried one axis. "An allow rule MUST NOT grant" was a complete instruction then: it means the rule steps aside, and stepping aside was harmless because whatever granted next also said allow. v1.28.0 gave rules a second axis and did not revisit it, so "does not grant" began silently discarding the approval requirement too.

    It is not confined to the legacy boolean. The trigger is an unevaluable approval rule, and §6.1.1 is the path that misconfiguration, §6.1.2's warn-don't-fail registration ordering and handler failure all take. A misspelled predicate (has_keys for has_all_keys) or an unregistered condition key reaches it with a governance projection present, on the ordinary Executor pipeline; default_effect: allow reaches it with no second rule at all. validate_rules() is not a mitigation — it cannot see the projection-absent route, and §6.1.2 makes an unregistered condition key a warning rather than a load failure.

    §6.1.1 rule 5 makes the requirement pending rather than discarded. It is recorded when the unevaluable allow rule's callers/targets match, composed by disjunction with whatever grants later — a subsequent allow rule or default_effect: allow — and cleared when the final decision is deny. A rule whose patterns do not match raises nothing, so a rule written about one caller cannot attach a human to calls it was never written about. A rule whose own pattern field is malformed does raise it: its scope cannot be read, so it cannot be shown not to apply, which is the posture that field already produces under deny, where it denies every call.

    Requiring a human rather than denying is deliberate. The condition that could not be evaluated is the one that decides whether this call is the dangerous one. Refusing would turn every ordinary git push into the hard failure §6.1.7 exists to eliminate; "ask" is the answer that is wrong in neither direction.

    §6.9 rows 1 and 2 are amended — the requirement may originate in a rule that did not match, and default_effect: allow carries it, making approval_required: true with matched_rule_index: null a legal combination. §6.8.1's fail-closed rule is restated as a property of the decision rather than of the matched rule.

    Backward compatible for correct configurations: across all 20 cases of acl_argument_scoped_approval.json with a projection present, no decision changes. Without a projection, two change, both approval_required: falsetrue — and both therefore flip the legacy check() boolean from true to false, including for a benign git push that is authorized and needs no human. Every non-Executor caller of check() takes the no-projection path by default, so tooling reading that boolean against an ACL carrying an arguments-conditioned approval rule now sees a refusal where it saw a grant. That is §6.8.1's "wrong in the benign direction" working as designed — the condition that decides whether this call is the dangerous one could not be evaluated — but it is a visible change for check() consumers, not only a governance-internal one. This IS an SDK change in all three.

  • Fourteen Rust examples imported symbols the SDK does not have, and CI could not see any of them (#105). conformance/check_doc_examples.py's Rust arm matched only the crate-root brace form use apcore::{...}. Every nested import — use apcore::errors::X, use apcore::events::{Y} — matched nothing and was never examined. In the current tree that is 56 crate-root imports against 56 nested ones, so roughly half of the apcore imports in Rust examples were unchecked.

    check_doc_examples.py now resolves nested paths against a module map built from the SDK's src/ tree, reporting both an unknown module and a module that does not export the named symbol. A module that glob-re-exports maps to "contents not enumerable" and its symbols are skipped, so the check produces no false positives in the one direction a checker must never produce them. All fourteen findings were verified against apcore-rust before being fixed; none was a false positive.

    Four of the fourteen named types that do not exist anywhere: apcore::errors::PipelineStepError, apcore::middleware::MiddlewareContext, apcore::trace_context::TraceContextError, apcore::observability::UsageSummary. Those examples were not merely mis-imported — they taught API shapes the SDK does not have, and fixing them meant rewriting the example bodies:

    • core-executor.md demonstrated e.is::<PipelineStepError>() and downcast_ref, i.e. std::error::Error downcasting. PipelineStepError is an ErrorCode variant, not a type; the real API is ModuleError::is_pipeline_step_error() / unwrap_pipeline_step_error(), with the step name and cause in details.
    • observability.md matched Err(TraceContextError::InvalidParentId) from TraceContext::try_inject. There is no such method and no such error type: the checked form is inject_checked, returning a ModuleError carrying ErrorCode::InvalidParentId.
    • observability.md's UsageExporter implementation had both halves of the signature wrong — export(&self, _summary: Vec<UsageSummary>) -> apcore::Result<()> against a real export(&self, summary: &Value) -> Result<(), ModuleError>. apcore::Result does not exist either.

    The remaining ten were wrong module paths for types that do exist: ContextKey, ErrorFormatterRegistry and TraceContext live at the crate root rather than under context / errors / observability; A2AAuth and A2ASubscriber are in events::subscribers and not re-exported from events; ModuleDescriptor and DependencyInfo are in registry, not module; utils::call_chain, sys_modules::registration and sys_modules::errors are not modules at all.

  • A string where a list belongs turned an allow rule into a wildcard (#106, spec §6.1.4.1). callers: "admin.*" written where callers: ["admin.*"] was meant is iterated character by character in apcore-python, and the * character matches everything — so the rule granted access to every caller under default_effect: deny. Measured, not theorised: "admin.*" and "*" both returned true for an unrelated caller; "api.gateway" returned false only because none of its characters happen to match, which is luck rather than design. A non-subscriptable scalar raised TypeError out of check() in apcore-python and apcore-typescript, violating Contract: ACL.check's "check MUST NOT raise to indicate a deny"; apcore-rust is immune because ACLRule.callers is Vec<String>.

    ACL.load already rejects a non-list callers/targets; direct construction and add_rule() did not, which is the same door a non-mapping conditions came through. §6.1.4's precheck now covers the rule's structure rather than only its conditions tree, and a malformed callers/targets is unevaluable — an allow rule does not grant, a deny rule takes effect, and neither raises.

    Consequently the deploy-time validator is renamed validate_rules, since it now reports structural faults outside conditions. The narrower validate_conditions name has not shipped in any SDK.


Changed

  • An ACL condition that could not be evaluated silently disabled the deny rule carrying it (spec v1.22.0, #100). Condition evaluation returned a plain boolean, so "a handler answered no" and "no answer was obtainable" reached the rule loop identically and both meant this rule does not match. That is safe in one direction only: an allow rule that cannot evaluate its condition does not grant, but a deny rule that cannot evaluate its condition does not block — evaluation continues to the next rule and then to default_effect. A single misspelled key (role: for roles:) turned a rule its author believed was blocking into decoration. Reproduced end-to-end with effect: deny + a misspelled key + default_effect: allow, where check() returned true.

    PROTOCOL_SPEC §6.1.1 now names the three situations that make a condition unevaluable — no registered handler, a handler that raised/threw/panicked, an async handler unresolvable on the sync path — and requires the rule to resolve toward refusing access: a deny rule takes effect, an allow rule still does not grant. §6.3's algorithm is restated over three outcomes instead of two, §6.5 gains the three rows, and §6.3.1 documents the AuditEntry all three SDKs already emit — including handler_error, which no section had ever defined despite conformance/fixtures/acl_handler_error.json asserting on it.

    §6.1.2 handles discovery without breaking bootstrap order. register_condition() writes to a runtime, process-wide registry and acl.root discovery commonly runs before application code, so loading MUST NOT fail on an unregistered key; it MUST warn, naming the rule index, the key and the rule's effect, and implementations MUST provide an explicit validator to run once registration is complete — covering direct construction and runtime insertion, not only file loading.

    This is an SDK change in all three, and it changes a decision. conformance/fixtures/acl_handler_error.json pins the opposite behaviour today, under a case named throwing_handler_does_not_flip_default_allow_to_deny_unsafely. The corrected fixture is staged at planning/acl-unevaluable-conditions/staged-fixtures/ and lands after the three drivers, so CI does not go red across every SDK repository for the duration of the rollout.

    Deliberately out of scope: §6.5's "conditions present but no context provided", which stays a non-match. Calling with no context is a legitimate shape for external entry points, not a misconfiguration, and treating it as a failure would flip the decision for every @external call meeting a conditional deny rule. It gains a warning and an explicit note on the consequence instead.

    Precedent for the rule: §7.9.4(4) has required since v1.9.0 that a typo cannot silently disable an execution policy. ACL rules now carry the same guarantee.

  • schemas/acl-config.schema.json contradicted the condition extension point it was validating against. $defs.RuleConditions was additionalProperties: false, which rejects every key registered through the documented register_condition() API, and it listed time_window as though it were built in — no SDK registers such a handler, and docs/guides/acl-configuration.md presents it as a custom handler example. A reader who copied that guide got a schema-valid file whose deny rule never fired. The condition set is now open, with the reason recorded in the description, and time_window validates as any other custom key.

Added

  • ACL had no read-only accessor for default_effect (spec v1.23.0, #101). It is the single most consequential value in an ACL — §6.1 carries a danger admonition about setting it to allow — and no SDK exposed it: apcore-rust keeps a private field whose only public reader is rules(), apcore-typescript declares private _defaultEffect with no getter, and apcore-python's acl.py defines no @property at all, so neither rules nor default_effect has a public reader there. The specification was the origin — features/acl-system.md defined a Contract for check, load, discover, add_rule, remove_rule and reload, and no read-only surface at all, so there was nothing for an SDK to implement. New §6.8 makes both accessors MUST, requires them to be pure reads reachable through a documented public path, forbids rules from handing out a mutable reference into the ACL's own list, and requires both to reflect a reload(). Without it, tooling that reports or audits the enforced policy had to re-read and re-parse the ACL file to recover a value the loaded object already held — a copy that can drift across reload(), and on TypeScript and Rust the only option available at all. Additive and backward-compatible in every language.

  • Policy resolution could not see the call's arguments (spec v1.24.0, #102). Governance decided on which module was being called and never on what it was being called with: resolution took a module ID and the module's annotations, and a PolicyRule carried a module-ID pattern plus two boolean overrides — the shape §7.9.1(2) mandates, identical in all three SDKs. The data was never missing: the approval gate is Step 5 and the invocation's arguments and Context are in scope at the call site, which passed only the module ID. An operator who needs to gate some calls to a module therefore has to gate all of them, producing audit noise and weakening requires_approval from "this needs approval" to "this might" — the distinction §7.9.3 exists to preserve.

    New §7.9.6 requires resolution to receive the call site while forbidding the built-in pattern rules from consulting it, so a rule set's verdict stays a function of module ID and annotations alone and remains reproducible from the policy document. Two constraints are explicit: those arguments have not been schema-validated, because the gate is Step 5 and input validation is Step 7, so a host-supplied policy must not assume them well-formed; and adding the call site MUST NOT change the verdict any existing policy produces. A declarative argument predicate on PolicyRule is deliberately not specified — ACL rules already discriminate on caller, target, identity type, roles and call depth while policy rules discriminate on module ID alone, and adding a predicate to only one would grow a second condition language over the same decision point.

  • api-surface-conventions.md §9 — how SDK-owned data types are constructed across a package boundary. Written because every Rust approval and preview example in this repository failed to compile (#103).

  • api-surface-conventions.md §9.4 — a machine-readable fragment marker. §9.2 rule 5 requires a code block that is deliberately a fragment to say so, which is not an executable rule without a defined syntax. It is now <!-- apcore-example: fragment --> on the line before the fence: an unmarked block is claimed to compile, a marked one is exempt from compiling and from nothing else. Applied to the five blocks whose fragment status was established by compile-checking; the sweep across the remaining Rust blocks belongs to the harness in #105, because marking them by hand beforehand would be guesswork.

    §9.4 also documents what conformance/check_doc_examples.py actually covers, which is narrower than it reads and uneven across languages: Python checks from apcore… import … including submodules, TypeScript checks apcore-js imports and the declared subpath exports, and Rust checks only the crate-root brace form use apcore::{ … } plus ErrorCode:: variant names. A nested import such as use apcore::events::{EventRetryConfig} matches nothing and is never examined. The tree carries 56 crate-root brace imports and 56 nested ones, so roughly half the apcore imports in Rust examples are invisible to it — which is why the RetryConfig / EventRetryConfig mismatch passed CI, and why it was found by hand rather than by the checker.

Fixed

  • The async-condition rule contradicted §6.1.1 in three places. §6.1.1 lists "an async handler that could not be resolved on the synchronous check() path" as one of the three unevaluable situations, but three older passages still specified that case as unsatisfied: §6.1's compound-operator paragraph (as "MUST fail closed" — ambiguous once the two outcomes are distinguished, because on an allow rule not-matching is failing closed while on a deny rule it is failing open), features/acl-system.md's "Sync handler resolution" admonition, and spec/design-context-annotations-acl.md §3.6's pseudocode. Left as written, a deny rule guarded by an async-only handler stayed inert on the sync path — the same failure mode #100 was opened for, reached by a different route. All three now say UNEVALUABLE and point at §6.1.1; the design document, which is historical, gains the discrepancy in its superseded-parts list rather than being rewritten.

  • validate_conditions() had no defined answer for a key registered on only one path (§6.1.3, new). SDKs keep two condition-handler registries; async_check() consults the async one and falls back to the sync one, while check() consults only the sync one. A key registered only as an async handler is therefore a working condition under async_check() and an unevaluable one under check(). A validator reporting a single "registered" boolean would give a different answer depending on which registry an SDK happened to consult, and could not tell an unregistered key from one that is merely unusable on the path the application calls. Findings now carry sync_registered and async_registered separately, and a finding is emitted whenever sync_registered is false — including when async_registered is true.

  • handler_error was underspecified where it interacts with short-circuiting and with multiple failures. Two determinism holes: a condition skipped by a legitimate short-circuit was never evaluated, so it is now stated not to be unevaluable and not to set handler_error; and when several conditions in one check() are unevaluable, handler_error must now list them lexicographically by key, not in evaluation order. Evaluation order is not portable — serde_json's map is ordered while Python dict and JavaScript objects preserve insertion order — so "the first one encountered" would have written a different key into the audit log for the same rule in different SDKs.

  • Contract: ACL.add_rule did not carry the §6.1.2 warning requirement. §6.1.2 rule 4 makes runtime rule insertion an entry point that must be covered; the contract described only insertion and locking. It now specifies the warning, its content, and that insertion still succeeds.

  • spec/rfc-preview-method.md's pre-condition section read as outstanding work. It was marked resolved at the top and then listed three numbered steps to perform before Stage 2 SDK work could proceed — all of which have been done. Rewritten as history, with the actual issue link and the full list of types that ended up carrying #[non_exhaustive].

  • features/event-system.md used event.name in all three language tabs. No SDK has that field: it is event_type in apcore-python and apcore-rust, eventType in apcore-typescript. Found by compile-checking the block while fixing #103 — and worth recording that this block is a fragment, which did not stop it from being wrong in every tab, and that nothing in CI would have said so. That gap is now tracked in #105.

  • Nine Rust examples that did not compile, across five documents (#103). #[non_exhaustive] forbids struct-expression construction from outside the defining crate, including ..Default::default() — verified from a downstream crate against apcore-rust 0.27.0, which produces error[E0639]. apcore-rust's own integration tests already use the working form and say why; the knowledge never reached the documentation. Corrected in features/approval-system.md (trait declared request: ApprovalRequest where the SDK takes &ApprovalRequest; two struct literals), guides/cookbook-approval-flow.md (a comment claiming ApprovalResult has no Default — it derives one; another claiming CallbackApprovalHandler::new takes a closure returning a Future — it takes impl Fn(&ApprovalRequest) -> ApprovalResult; two struct literals), spec/rfc-preview-method.md (a PreviewResult / Change literal, and a pre-condition section describing #[non_exhaustive] as not-yet-applied when it has shipped), features/async-tasks.md and guides/middleware.md (three RetryConfig literals), and features/event-system.md, which imported RetryConfig from apcore::events where the exported name is EventRetryConfig.

    Also corrected in guides/middleware.md: a comment stating use_middleware returns Result<(), ModuleError>, stale since spec v1.21.0 gave it a MiddlewareHandle.

    The two blocks central to the issue — approval-system.md's SlackApprovalHandler and cookbook-approval-flow.md's PolicyCheck — were made complete and verified with cargo check against the local apcore-rust, which required importing Executor, defining registry / config, removing a duplicate let mut executor, and stubbing the ask_slack / slack::ask_approval helpers the Python and TypeScript tabs invoke without defining. The rest remain fragments, now carrying the §9.4 marker; §9.2 rule 5 distinguishes the two cases and requires a fragment to be correct in every line it does show.

  • guides/cookbook-approval-flow.md demonstrated a cross-language divergence by accident (#104). Its Python and TypeScript tabs used an async callback with CallbackApprovalHandler for a Slack round-trip, and its Rust tab claimed the Rust constructor accepts one. It does not — apcore-python and apcore-typescript take an async callback, apcore-rust takes a synchronous impl Fn(&ApprovalRequest) -> ApprovalResult — so an approval decision that performs I/O fits the convenience handler in two SDKs and not in the third. That limits the convenience wrapper, not the SDK: the ApprovalHandler contract is async in all three, so apcore-rust expresses an async decision by implementing it directly. The Rust tab now implements ApprovalHandler directly, with the divergence stated rather than papered over. Whether the convenience handler should be capability-equivalent across SDKs is open in #104.

Fixed

  • Seven specification passages that no implementation satisfied, or that contradicted another passage. Each was verified against the three SDKs before editing; none required an SDK change.

    • Contract: SubscriberCircuitBreaker.on_failure described an API nobody implements. It declared a required subscriber_id input — the breaker is per-subscriber and already knows which one it guards, and no SDK accepts the argument — and declared CircuitState as the return, where apcore-python (circuit_breaker.py:125), apcore-typescript (circuit-breaker.ts:107) and apcore-rust all return the optional lifecycle ApCoreEvent. Both corrected to the shape all three ship.

    • apcore.event.delivery_failed's payload was declared two ways. PROTOCOL_SPEC §7's event table listed event_type, reason, subscriber_id; features/event-system.md § Dead-Letter Queue lists subscriber_type, subscriber_id, original_event, error, attempt_count, timestamp. The two overlap on one key. All three SDKs implement the feature-page shape, so the spec table — what a consumer building from the normative document would follow — was the wrong one. It now defers to the feature page.

    • bindings.files was a MUST nothing could satisfy. No SDK implements it, BindingsConfig is additionalProperties: false over {dir, pattern} so the key is schema-invalid, and config_key_governance.json allows only those two. Withdrawn, with the reason recorded, rather than left standing as an unmet requirement.

    • The reserved error-code prefix list named four (MODULE_/SCHEMA_/ACL_/GENERAL_) inside a normative block. The canonical set is fourteen, stated in features/error-system.md and matching FRAMEWORK_ERROR_CODE_PREFIXES in all three SDKs. A module author reading only the specification would pick CONFIG_* or BINDING_* and be rejected by every implementation.

    • §12.8.5 cited "call() Step 6" for the schema validation validate() reuses. Step 6 has been the Middleware Before Chain since v0.18; input validation is Step 7, which §12.8's own opening already said.

    • registry-system.md's reserved-namespace table listed five of the eight prefixes §2.5 reserves, omitting plugin.*, schema.* and acl.*, and described apcore.* as register_internal()-only where §2.5 records it as reserved with no current use. All eight are now listed, with a note on why ephemeral.* is enforced by prefix rather than as a reserved word.

    • core-executor.md described GovernanceState as "eight booleans" directly above a table listing nine. PROTOCOL_SPEC §6.6.5 has it right ("eight observations plus one derived flag"); v1.16.0 added the ninth field without updating the prose.

Changed

  • dependencies is now a parsed field on the module descriptor in all three SDKs (spec v1.18.0, #90 follow-up). PROTOCOL_SPEC §12.2 has required since v1.10.0 that a dependencies entry in metadata reach the registered module's descriptor "so that get_definition(module_id).dependencies returns what the caller declared", and the v1.10.0 row closed with "No SDK behaviour change: all three already satisfy the requirement."

    That was true of the data surviving and false of the accessor. Only apcore-rust carried a parsed Vec<DependencyInfo>. apcore-python surfaced the unparsed list nested under descriptor.metadata, so hasattr(descriptor, "dependencies") was False. apcore-typescript surfaced it on neither getDefinition().dependencies nor descriptor.metadatamergeModuleMetadata extracts dependencies as a canonical field, so it never landed in the metadata bag either.

    That had a shipping consequence: system.manifest.* in apcore-typescript read descriptor.metadata['dependencies'] and therefore reported dependencies: [] for every module that declared them, while apcore-python reported the real list over the same wire contract. Verified before and after; the two now emit identical payloads.

    The requirement is restated as a parsed field because the distinction is the point. metadata is defined by the get_definition Returns table as arbitrary extension data — the x- layer of the three-layer model — while dependencies are structural data the framework itself consumes for load and reload ordering. Carried in the extension bag, the {module_id, version?, optional?} parse falls on every consumer: sys_modules/control.py imported parse_dependencies inside its reload function to do exactly that.

    §12.2's rationale is corrected alongside. It read "Reload ordering reads that accessor", which holds only for apcore-rust — apcore-python and apcore-typescript order reloads from get_module_metadata(). That is the sole reason the missing field never surfaced as a bug, and it left a trap: refactoring either reload path onto the more natural-looking get_definition() would have silently degraded ordering to its sort's alphabetical seed order. Tests in both SDKs now assert the two accessors agree.

    features/registry-system.md's get_definition Returns table gains the dependencies row it never declared — the table listed twelve fields while the register Contract on the same page referenced a thirteenth.

    display and enabled, which apcore-rust also carries on its descriptor and the table also omits, are deliberately left open rather than bundled: display is accepted as a parameter by all three but promoted only by Rust and is named nowhere in the specification, and enabled is not a shared concept — Rust has a registry-level flag mutated by Registry::enable/disable while the other two route enable/disable through ToggleState (system.control.toggle_feature). Unifying that means deciding which enable/disable mechanism is canonical, which this change has no business answering.

    Governance: maintainer approval per GOVERNANCE.md § Decision Making; no tracking issue was opened.

  • Contract: APCore.remove required an identity removal apcore-rust could not provide, for a reason it had recorded wrongly (spec v1.21.0). The contract removes by IDENTITY: apcore-python and apcore-typescript take the middleware object back and compare with is / ===. apcore-rust exposed remove(&str) plus a remove_middleware(&dyn Middleware) that resolved to remove(middleware.name()), and a doc comment explained the divergence as "trait objects do not support identity comparison".

    That is false — Arc::ptr_eq has ignored vtable metadata since Rust 1.76, below the crate's MSRV — and false in the direction that made the gap look unfixable. The real obstacle is ownership: use_middleware consumes the Box, so a caller has no pointer left to compare against. The reason determines the fix, which is why the wrong one mattered.

    Reachable, not theoretical: duplicate registration only warns and always succeeds, so two instances answering one name() coexist. Verified against the pre-fix code — a caller holding the second of two "audit" middlewares called remove and lost the first.

    use_middleware now returns a MiddlewareHandle; remove_handle(handle) removes exactly that registration. It mirrors EventEmitter::subscribeunsubscribe_handle, which exists for the same reason on the event bus. Two normative statements follow: an SDK that cannot take the middleware object back MUST provide a token issued at registration that removes exactly one registration, and MUST NOT present a name-based removal as satisfying this contract. Backward compatible — the added return value is discarded by every existing ?; call site, and both name-based forms remain.

  • Contract: Registry.register described a TypeScript API that does not exist (spec v1.20.0). It declared async: false and void (TypeScript); register there returns Promise<void>. on_load is synchronous in apcore-python and apcore-rust — the Rust trait signature enforces it — while apcore-typescript additionally accepts an async onLoad and resolves once it has run. Everything else about registration is synchronous in all three: ID validation, the duplicate check and every other error throw synchronously, and a module with no onLoad or a synchronous one is visible before the promise resolves.

    This is not only a reader-facing inaccuracy. A module written with async def on_load and registered through apcore-python was published and callable with none of its initialisation having run: the coroutine was created, never awaited, and discarded, leaving only a RuntimeWarning at the next garbage collection, attributed to whatever code happened to be running then. That is exactly the half-initialised module the deferred-publish design exists to prevent, reached through the one path that skipped the check.

    The contract states the real shape, and gains two normative statements: an SDK whose register awaits an async load hook MUST keep the module invisible until it completes, and MUST NOT publish a module whose load hook it cannot run. apcore-python now refuses an awaitable on_load with MODULE_LOAD_ERROR and leaves the module unpublished — the same outcome any other failing on_load gets. Nothing can depend on the previous behaviour, since an async def on_load has never once run.

    The version-history table also gains its missing 1.19.0 row: the §9.14 recursion change bumped the header and the description but never recorded itself in the table.

  • check_expected_keys_read.py reported "0 unread" partly by accident. Its is_read() searched a key literal across EVERY test file in each SDK repo, with no association to the fixture that declared it. Measured on the current tree: stream_aggregation's b matched in 124 unrelated files, schema_strict_conversion's type in 108, properties in 57. The visible symptom was the reverse direction — it reported the live allowlist entry redaction_config.json: amount as stale on the strength of one unrelated schema-coercion test in apcore-typescript.

    Scoping the search to a fixture's own drivers, the way check_driver_coverage.py already resolves that relationship, then exposed the opposite failure: the BEST-written drivers name no key at all. usage_contract's three drivers each end in a loop over expected comparing every entry, and were reported as leaving five keys unasserted. So the checker now also recognises a driver that iterates or deep-equals the whole expected map, in each language's idiom.

    Two allowlist entries were removed rather than kept: sensitive_keys_default.json and redaction_config.json were exempted for exactly the reason the checker now detects. approval_gate.json's stays — no SDK exposes the code-to-HTTP mapping it names.

    Verified by removing the wholesale assertion from all three usage_contract drivers at once: the checker reports its five keys, and reports nothing when only one of the three is removed, which is the correct reading of "no driver asserts it".

  • The documented config version: was an SDK release number in nine places. apcore-config.schema.json describes that field as "Configuration version" and offers 1.0.0 as its example. PROTOCOL_SPEC §9.6 filled it with 0.14.0 across seven blocks, config-bus.md with 0.15.0, and apcore-typescript's README with 0.26.0 — three different values for one field, none of them a configuration version, every one stale the moment the next release shipped. §9.1's own example, on the same field, uses 1.0.0.

    Not cosmetic: a reader copies the block, and the number they copy looks like something to keep in step with their dependency. The same misreading seeded apcore-rust's default table, which carried version: "0.16.0" as "the frozen baseline spec version" while both peers supplied no default at all.

    All nine now read 1.0.0. conformance/check_config_version_examples.py guards it, reading the sanctioned set out of the schema's own examples rather than a second copy of it, and skipping ACL policy blocks — acl-config.schema.json owns its own version and accepts the two-part form its examples use. Wired into conformance-integrity.yml.

  • The §8.2 reachability rule taught itself with a resolved example. api-surface-conventions.md presented apcore.registry.registry.MAX_MODULE_ID_LENGTH as a live break "tracked in the apcore-python repo". Verified against apcore-python 0.27.0: the constant is exported from both apcore and apcore.registry, and named in both __all__s. Kept as the worked example — it is what the rule looks like when it fires — but marked as fixed rather than outstanding.

  • sys-health-*.schema.json rejected the output every SDK emits. The status enum was ["healthy", "degraded", "unhealthy"]. All three SDKs classify as healthy / degraded / error / unknown and emit unhealthy nowhere; neither does any page under docs/, conformance/ or README.md. system-modules.md's own classification table lists the four the SDKs use. Corrected to those four, along with sys-health-summary.schema.json's unhealthy COUNT field, which splits into error and unknown for the same reason.

    Two adjacent gaps surfaced while validating real output against the corrected file: the summary's modules[] items declared only module_id and status under additionalProperties: false, while all three SDKs also emit error_rate and a top_error object — so the schema rejected a conforming payload on two counts, not one. Both are now declared, top_error with the {code, message, ai_guidance, count} shape the three SDKs build identically.

    Verified by validating apcore-python's live system.health.summary / system.health.module output and the worked example in docs/features/system-modules.md against the corrected schemas, and by confirming the previous version rejects both.

  • §9.14's unknown-key walk is recursive, and was specified as one level (spec v1.19.0). schemas/apcore-config.schema.json and its siblings are additionalProperties: false at every level, not only at the section root: observability.tracing, acl.audit, validation.binding and obs.redaction are each closed in their own right. reject_unknown_framework_keys iterated only a section's direct children, so strict mode was blind exactly where a typo is hardest to spot — observability.tracing.sampling_rat passed the check because its parent tracing is declared, while the canonical schema rejects it, and the misspelled sampling rate fell back to its default with no error and no log line.

    The pseudocode was the outlier, not the schema. apcore-typescript already walked the full depth; apcore-python and apcore-rust matched the one-level pseudocode and are corrected. This is the specification catching up to a closedness it already declared, so it is a MINOR bump rather than a new requirement: strict still defaults to false, and no configuration changes behaviour unless its author opted in.

    An undeclared subtree is reported once, at the point it stops being declared, rather than once per key beneath it — a misspelled section otherwise produces an error per leaf and buries its own cause.

    Both SDKs' key surfaces moved from a section -> direct child names map to the flat dot-path list apcore-typescript already used, since the map shape could not express nested closedness and so could not guard it. Their drift guards moved with it and now compare full paths at every depth. Two follow-on gaps surfaced from that: apcore-rust's load-path coverage guard read the section table and therefore never noticed that $schema — a top-level key an operator can write and the schema accepts — had no load-path coverage at all, and apcore-rust had no equivalent of apcore-python's guard that every section enforced as closed is closed upstream. Both added.

    Governance: maintainer approval per GOVERNANCE.md § Decision Making; no tracking issue was opened.

Fixed

  • PROTOCOL_SPEC §9.15.3 declared the sys_modules activation flags True while citing a schema that says false (spec v1.17.0). The Config.register_namespace("sys_modules", ...) block passes schema="schemas/sys-modules.schema.json" and then, four lines below, declares defaults={"enabled": True, ..., "events": {"enabled": True, ...}}. That schema declares both keys default: false. So does §6.6.3 of the same document, which states the 0 / 6 / 9 activation ladder in terms of exactly those two flags; so does conformance/fixtures/config_defaults.json; and so do all three SDKs. §9.15.3 was the only dissenting authority, and it dissented from a file it names on the preceding line.

    Read literally it told implementers to register the six read modules in every project that never asked for them, and — through events.enabled — the three system.control.* write modules, which are the approval-gated control plane. Both activation flags are now False. The per-module sub-flags (health, manifest, usage, control) stay True: they select which modules register once activation has happened rather than activating anything themselves, and sys-modules.schema.json declares them true for the same reason. Only the two keys the schema disagreed with were touched.

    No behaviour change: no SDK, schema or fixture moves. It does explain a real cross-language divergence found in the same sync — apcore-rust's Config::namespace() merges registration defaults on every call in both config modes, so in legacy mode it reported sys_modules.enabled = true sourced from this block while apcore-python and apcore-typescript reported the schema's false. Rust was faithfully implementing one half of a self-contradictory specification; correcting §9.15.3 removes the contradiction at its origin rather than papering over it in an SDK.

    Governance: maintainer approval per GOVERNANCE.md § Decision Making; no tracking issue was opened, recorded here rather than pointed at an invented number (the precedent set by the 1.13.0 row).

  • docs/spec/algorithms.md A20 guard_call_chain() contradicted conformance/fixtures/call_chain.json on 8 of its 11 cases. The pseudocode assumed call_chain EXCLUDES the module being called, while docs/features/call-chain-guard.md states it "already includes module_id at the end, as set by Context.child()" and the fixture pins that reading. Under the published algorithm the ordinary chain [a, b, c] calling c raised CIRCULAR_CALL, a single-element chain raised CIRCULAR_CALL, and a 32-element chain at the default limit of 32 raised CALL_DEPTH_EXCEEDED. An implementer following algorithms.md — which the index marks MUST — could not pass the conformance suite.

    Corrected on four axes, each verified against all 11 fixture cases: the depth and frequency comparisons are strict (>), so a chain sitting exactly on a limit passes; cycle detection scans the prior chain for a last occurrence with entries after it, so [a, a] is a self-call and [a, b, a] is a cycle; the limit floors (max_call_depth < 1, max_module_repeat < 1) are rejected before the chain is inspected at all; and the parameter is named max_call_depth, matching the feature doc and all three SDKs rather than the algorithm's max_depth. The fixture's INVALID_LIMIT label is documented as a fixture-level expectation, not a wire code — each SDK raises its own idiomatic invalid-input error there (divergence T-B-005).

  • README.md's component-version table was stale in five of its six rows. Protocol specification read 1.12.0 against an actual 1.17.0; the core SDK line read 0.26.0 against 0.27.0; apcore-mcp 0.17.2 against 0.18.1; apcore-a2a 0.4.4 against 0.6.0; apcore-cli 0.10.4 against 0.10.5. Verified against each repository's own build config (pyproject.toml / package.json / Cargo.toml), and all four adapter families carry the same version across their three languages. Only apcore-toolkit (0.10.1) was already correct. Nothing checks this table, so it drifts silently while reading as the authoritative release inventory.

  • docs/spec/algorithms.md A24.1 deep_merge_chunks discarded data at the depth cap, contradicting its own Properties note. Step 1 of the helper was If depth >= max_depth → Return, a bare return that drops the override sub-object — while the Properties block twelve lines below states "the truncation point loses no data", docs/features/streaming.md states "the right value replaces the left at that level", and conformance/fixtures/stream_aggregation.json pins right-value-wins as deep_merge_depth_cap_right_wins. The pseudocode encoded the exact bug that fixture tracks as divergence T-B-002, so an implementer fixing apcore-python or apcore-typescript against algorithms.md would have reintroduced it. The cap now assigns the override wholesale and stops recursing. The inline case count for that fixture was also stale (9 against an actual 10).

Added

  • conformance/fixtures/usage_contract.json (11 cases) and governance_state.json (12 cases), driven by all three SDKs (#96, #97). The two sections that were marked PENDING now cite landed fixtures, and docs/features/core-executor.md drops its not-implemented banner: governance_state() ships in apcore-python, apcore-typescript and apcore-rust.

    usage_contract.json pins the two value semantics no JSON Schema can assert — a full-history call_count and an off-by-one p99_latency_ms are both well-typed numbers in the right field. governance_state.json includes the lookalike case (a custom step named acl_check) and the three cases that discriminate the corrected v1.16.0 derived flag from the unsound one published in v1.15.0.

    Both were verified against a mutated fixture before landing: flipping the discriminating governance case to the v1.15.0 answer, and the p99 worked example to 100, reds exactly those two cases in all three SDKs. A case that cannot go red is not coverage.

    Two adjustments the drivers forced, recorded in each fixture's driver_contract. Backdated records are expressed as at_offset ("-2h") rather than absolute instants, because the SDKs window against the real clock and a fixed date would put every record outside every window — the period cases would pass for the wrong reason. And the unattributed-caller case must be recorded through the SDK's own usage-recording path, not by handing a null to UsageCollector.record(): apcore-python and apcore-typescript substitute "unknown" in UsageMiddleware while apcore-rust does it in the caller breakdown, so driving the collector directly tests a different API in each SDK.

Fixed

  • unprotected_control_surface as published in spec v1.15.0 was unsound; corrected in v1.16.0 (#97). The formula treated a wired approval gate plus either a handler or ExecutionPolicy(strict=true) as sufficient to conclude that a gate stands in front of system.control.*. It is not, because the two gates are not symmetric: acl_check evaluates every call, while approval_gate resolves per module and returns before consulting the handler when the module does not need approval — all three SDKs short-circuit there (apcore-typescript builtin-steps.ts:401, apcore-python builtin_steps.py:453, apcore-rust builtin_steps.rs:623). Since §6.7 makes requires_approval on control modules a SHOULD, an ordinary conformant deployment can register system.control.* modules that the gate never engages on, and the published flag reported that as gated. A false false — the one direction §6.6.5.2 declares the flag must never fail in, written into the formula by the same change that declared it forbidden. Adds a ninth field all_control_modules_require_approval as a required conjunct, new §6.6.5.1.1 explaining the asymmetry, and four fixture cases of which three discriminate the corrected formula from the published one. No SDK had implemented governance_state(), so nothing shipped the wrong behaviour.

  • Two Pinned by conformance/fixtures/... citations named files that do not exist (#96, #97). §6.6.5.4 cited governance_state.json and §6.7.1.6 cited usage_contract.json; both are held in planning/ until the SDKs implement and drive them, which the same change documented. A citation that reads as coverage while nothing is asserted is the exact defect this repository's fixture guards exist to prevent. Both now say PENDING, link the planning draft, and state that the sections are specified-but-unverified. docs/features/core-executor.md likewise gains a warning that governance_state() ships in no SDK today — it was showing call examples for an API that does not exist.

    A pre-existing instance of the same defect is fixed alongside: §"Display Resolution" cited conformance/fixtures/display_resolve.json as "already created". It was never created, and no other fixture covers display resolution.

  • The §2.1 reserved-word mitigation overstated what the SDKs enforce (#98). The replacement text said §2.6's per-segment rule and "the equivalent check in each SDK's public register()". They are not equivalent: all three check the first segment only (registry.py:286, registry.ts:204, registry.rs:388, each commented "reserved word first-segment check"), so foo.system.bar is rejected by §2.6 as written and accepted by every implementation. The mitigation now describes the implemented behaviour — sufficient for T8, because only the first segment can impersonate system.* — and records the spec/implementation divergence explicitly instead of papering over it. The §2.6 divergence itself is a real, pre-existing spec bug and is tracked separately.

  • The sys. guard's allowlist widened itself (#98). The pattern matched one segment after sys., so an entry keyed on sys.control exempted every sys.control.* there is — a newly-introduced sys.control.shutdown was silently allowed, which is the one thing the guard exists to catch. The pattern now matches the full dotted ID, allowlist keys are full IDs, and host-language / config-path exclusions compare the first two segments. The conformance-integrity path filter also now covers examples/**, CONTRIBUTING.md and llms.txt, which the guard scans and the filter did not.

  • Three count contradictions in the #96 material. §6.7.1 said the implementations "diverged in four ways" where §14 and this file say five (five is right — the issue's four plus the period grammar). planning/usage-contract-parity/overview.md said 16/16 for a set that is fifteen plus two. And §6.7's preamble still said "the SDK source is the schema source of truth", contradicting §6.7.1 declaring the two new files canonical; it now states that a shipped canonical schema wins and the SDK source is the description of record only where none exists.

Added

  • PROTOCOL_SPEC §6.7.1 "Usage Module Output Contract" and the two system.usage.* schemas (spec v1.14.0, #96). §6.7 named the two usage modules, required "equivalent input/output schemas", and deferred the field contract to each SDK's source. Three implementations diverged in five ways without any of them becoming non-conformant, because nothing said what the fields mean. Now normative:

    • period is a filter, not an echo. Grammar ^[1-9][0-9]*[hd]$, declared as a pattern in input_schema so a malformed value fails at input validation with SCHEMA_VALIDATION_ERROR in every SDK — apcore-python's parser accepted "0h", "-5d" and "+3h", apcore-typescript's rejected all three, apcore-rust parsed no period at all. Every statistic in both outputs MUST be computed over [now − period, now]; apcore-rust echoed period back while every number behind it covered the full retained history, which is silent by construction — the response names the window it did not apply.
    • hourly_distribution[].hour is YYYY-MM-DDTHH, the key UsageCollector already produces in all three SDKs, emitted verbatim. apcore-rust reformatted it to %Y-%m-%dT%H:00:00Z behind a constant documented as "matching UsageCollector bucket hours" while bucket_key produced %Y-%m-%dT%H — internally inconsistent, not merely different. This repository's own example in docs/features/system-modules.md showed the reformatted spelling, so the divergent implementation was the one following the docs; the example is corrected here. Exactly 24 entries, ascending, zero-filled; the 24-entry span is fixed and period filters the counts inside the buckets, not the array length.
    • p99_latency_ms is nearest-ranksorted[min(ceil(0.99·N), N) − 1], no interpolation, 0 on an empty sample set. apcore-python computed that index and then discarded it, returning sorted[rank], one element higher; for 100 samples it answered 100 where apcore-typescript and apcore-rust answered 99. A worked example is stated normatively because no schema can assert a value disagreement inside a number field.
    • trend thresholds (> 1.2 rising, < 0.8 declining, zero-cases decided first) and unattributed calls as the literal caller_id "unknown" — never null, never omitted, never the ACL token @external.
    • output_schema() MUST declare properties and required. apcore-rust returned a bare {"type": "object"} for both modules, which satisfies "equivalent output schemas" only in the sense that any two such declarations are equivalent to each other.

    schemas/sys-usage-summary.schema.json and schemas/sys-usage-module.schema.json are the canonical shape, $id on https://apcore.dev/ to match the existing fifteen. Both carry additionalProperties: false, and the hour pattern deliberately rejects apcore-rust's current output — that is the assertion that fails until the SDK work lands, and it must not be relaxed to accommodate :00:00Z.

  • PROTOCOL_SPEC §6.6.5 "Governance State Query" (spec v1.15.0, #97). A read-only governance_state() on the Executor returning seven observations plus one derived flag, with normative field names across the three SDKs. acl != null is not the answer to "what is gating this registry": the ACL and approval gates are pipeline steps, and three of the four strategies this specification defines (internal, testing, minimal) remove acl_check — so an adapter reading acl.is_some() reports "protected" in precisely the configuration set_acl() is already warning about. builtin_acl_gate_wired / builtin_approval_gate_wired MUST be determined by step type or capability, never by step name, because StrategyInfo carries names only and a custom step named acl_check would otherwise report a gate that is not there. unprotected_control_surface is defined exactly and is explicitly not a security verdict — it reports the absence of a recognised gate, never the presence of protection; an is_secure-shaped field is forbidden. Purely additive: no default changes, no behaviour changes, and apcore-rust's existing public acl / approval_handler / policy fields are untouched.

  • conformance/check_module_namespace.py — a guard on the sys. module-ID namespace (#98). The control plane is system.*; sys is not reserved. Host-language spellings (sys.path, sys.exit) and the sys.modules.* Config Bus key path are excluded by construction; anything else needs an allowlist entry with a reason, reported STALE once it stops matching. Wired into the conformance-integrity workflow.

Changed

  • PROTOCOL_SPEC §6.6.3 rewritten — Layers 2 and 3 are inactive by absence (spec v1.15.0, #97). Layer 1 registers 0 / 6 / 9 modules across two config flags, not one: sys_modules.enabled alone gives the six read modules, and the three system.control.* write modules require sys_modules.events.enabled as well, because their audit events need the EventEmitter. A registry with six read-only modules and no ACL is an information-disclosure question, not a control-plane one, and conflating the two produces a warning that fires on a configuration with no write surface. New §6.6.3.1 states that a missing acl/ path attaches nothing and MUST NOT synthesize an empty default-deny ACL (which would deny every inter-module call in every project without an acl/ directory), and that a missing ApprovalHandler warns and continues unless ExecutionPolicy(strict) is set. New §6.6.3.2 states that a strategy without the step disables the layer even when the object is configured, with the preset table showing which three do it.

Fixed

  • The system modules are system.*, not sys.* — six occurrences, and one of them described the reserved-word mechanism backwards (#98). sys is not a reserved word: PROTOCOL_SPEC §2.5 reserves eight (system, internal, core, apcore, plugin, schema, acl, ephemeral) and the three SDKs reserve the first seven, so sys.control.reload_module is an ordinary ID anyone can register, naming a module that does not exist.

    • docs/features/index.md and docs/features/error-system.md — wrong namespace in a navigation title and an error-table description. The error code SYS_MODULE_REGISTRATION_FAILED is deliberately unchanged; renaming a stable error code is a breaking change.
    • docs/features/core-executor.md — stated that reserved prefixes are permitted "so that sys.* invocation is legal", which is backwards in both halves. Rewritten to say what the bypass actually does: system is the reserved first segment, so the bypass is what makes system.* invocation legal, and every other validation still applies.
    • docs/spec/security-considerations.mdthree further occurrences the issue did not count, and the worst of them. The §2.1 mitigation claimed Algorithm A01 rejects IDs beginning with sys., system. or apcore., "verified by fixture normalize_id". A01 (directory_to_canonical_id) performs no reserved-word check at all — that is §2.6 detect_id_conflicts step 2 — sys is not reserved, and normalize_id covers Algorithm A02 with no reserved-word case in its 16. Three wrong claims in one security mitigation, now corrected, with the absence of fixture coverage recorded rather than papered over. The §3.2 audit checklist told operators to check for ACL rules targeting sys.control.* — a pattern that matches no module and enforces nothing, which is the same silent failure mode as apcore-mcp#14, reproduced inside the security document itself.
  • docs/spec/conformance.md §8.1 fixture inventory was three ways out of date, and CI was red on it. preflight_disclosure (4 cases) was absent entirely, schema_keyword_parity was recorded as 119 against an actual 122, and the Total read 664 / 60 fixtures against an actual 671 / 61. The conformance-integrity workflow verifies all three, so main had been failing since 019eaa4 and every PR touching docs/, schemas/ or conformance/ inherited the failure.

  • The apcore#96 governance citation on spec v1.13.0 pointed at an unrelated issue. The preflight-disclosure change cited a reserved issue number that was never opened; #96 has since been assigned to the system.usage.* schema work. The citation is withdrawn in both §14 and the 0.27.0 entry rather than repointed — recording that there is no tracking issue is better than inventing a link to one.


[0.27.0] - 2026-08-14

First non-draft release of the specification. Every prior version in the §14 version history is recorded as X.Y.Z-draft; 1.9.0 is the first finalised one. Governance gate: apcore#79 (linked issue + maintainer approval per GOVERNANCE.md § Decision Making).

This section contains BREAKING specification changes. No implementation had provided the superseded v1.8.x behaviour for any of them, verified per item against all three SDKs, so no deprecation cycle is owed and no dual-accept window applies.

Release note: this section contains BREAKING specification changes.

Added

  • conformance/fixtures/preflight_disclosure.json (4 cases) and §12.8.5.1 — a failed acl check withholds module-level introspection (spec v1.13.0; no tracking issue — the #96 cited here originally was a reserved number now held by an unrelated issue). Executor.validate() looked the module up at Step 3 and ran preflight() and preview() at Check 7 on the strength of that lookup alone, so a caller the ACL had just denied still made module-authored code run and still received what it returned. For a command-wrapping module that is the resolved binary and its argv; for a writer it is the target of the side effect. All three SDKs did it, each guarding only on "module lookup succeeded" — and apcore-mcp-rust had already grown a string-matched disclosure filter over the top (async_task_bridge.rs), which is the evidence the gap was reachable in a shipped product rather than theoretical.

    validate() now MUST NOT invoke either hook, emit a module_preflight / module_preview check, or populate predicted_changes when acl failed. The failed acl check itself is still reported and no other check is suppressed: the rule is about authorization, not validity, so a malformed input from a permitted caller still gets the module's own account of what would happen — which is exactly what the caller needs in order to fix the call. Fixed in apcore-python, apcore-typescript and apcore-rust.

    The fixture's control case matters as much as its denial cases: without one, an implementation that never introspects at all passes the denial cases for the wrong reason. Its driver_contract also requires observing hook invocation from inside the hook bodies — an implementation that calls the hooks and discards their results still ran module code for a denied caller.

  • Three format-in-a-union cases in schema_keyword_parity.json. The annotation rule (type-mapping §11.1) was pinned only where format sat at the top of a property. The sharp case is a value no sibling branch can rescue — {anyOf: [{string, format: email}, {string, maxLength: 3}]} against a 12-character non-email — where an implementation asserting format on a branch has no matching branch left and rejects. A third case pins that format does not discriminate a oneOf: two type: string branches both match, so exclusivity fails and the value is rejected, where treating format as an assertion would leave exactly one branch matching and wrongly accept. No SDK behaviour change — all three already passed on the first run, so this was a coverage gap, not a defect.

  • type-mapping §17 "Validation Keyword Conformance". A normative MUST/MAY table for every Draft 2020-12 validation and applicator keyword, with four general rules: R1 no silent drop (a keyword that cannot be enforced fails at load with SCHEMA_PARSE_ERROR), R2 keyword inertness on instances of other types, R3 adjacency with type (both are independent assertions), R4 conformance asserted by fixture. Replaces the "Complex combinators have partial support" note, which was too vague to judge an implementation against. §3.1 / §3.2 / §10.3 extended to the full §6.4 / §6.5 / §10.3 keyword sets.

  • conformance/fixtures/schema_keyword_parity.json (119 cases). Keyword handling parity at the validation boundary — the path a module call actually takes. Its driver_contract states this explicitly, because the previous generation of fixtures drove only the raw-JSON-Schema validator, and that is precisely why a whole class of converter defects stayed invisible for several releases.

  • conformance/fixtures/schema_strict_conversion.json (16 cases). The exact A23 output all three SDKs must emit.

  • conformance/fixtures/openai_strict_compat.json (30 cases). The OpenAI structured-outputs keyword set backing BindingStrictSchemaIncompatibleError, recording its source URL and verification date so it can be re-checked as the provider evolves.

  • driver_contract on schema_hardening_formats.json. States which halves of that fixture are normative: the valid: true half holds on all three SDKs at the module boundary; the warn_logged half does not pin equivalent behaviour, and format_mappings is aspirational metadata no SDK implements.

  • A23 required is emitted sorted (algorithms A23 step 2f, §4.16). The algorithm said only "node.required ← all_property_names" and both worked examples showed insertion order, while schema_strict_conversion.json pins a sorted list and all three SDKs already sort. The sort is now normative, specified as Unicode code point order (JavaScript's default Array.prototype.sort() compares UTF-16 code units and orders supplementary-plane names differently), and both worked examples corrected.

  • §12.2 Interface: Registry states register, and metadata.dependencies is now normative (#90). The normative component interface declared discover, get, list and describe — not register, the most-used entry point on the component and the one every SDK exposes with a four-argument signature. protocol-spec.md §12.2 now states register(module_id, module, version?, metadata?), and requires that when an implementation accepts metadata, a dependencies entry — a list of {module_id, version?, optional?}MUST reach the registered module's descriptor, so get_definition(module_id).dependencies returns what the caller declared.

    That requirement existed nowhere, which is why all three SDKs lost it independently and all three fixed it independently (apcore-python ad2998d, apcore-typescript#35, apcore-rust 71295e1). The loss is quiet by construction: discovery-time dependency sorting reads its own parse and keeps working, so resolve_dependencies looks healthy while the post-registration accessor returns nothing and a dependency-ordered reload degrades to its sort's seed order — usually alphabetical, therefore plausible, therefore not reported. Convergent behaviour with nothing holding it in place is the arrangement that produced the divergence in the first place.

    version is stated as an OPTIONAL parameter only, not as resolution. All three SDKs accept version and metadata; only apcore-python resolves by version. §5.4 continues to govern multi-version coexistence as optional, and get(module_id) keeps its single-argument normative form. Making resolution a MUST would put a requirement into the specification that two of three implementations do not provide — the exact shape 1.9.0 spent a release removing. The ordered side effects, the in-flight reservation and the visibility rule stay in features/registry-system.md § Contract: Registry.register; §12.2 carries the signature and the data-survival requirement. No SDK behaviour change — all three already satisfy it. No anchor ID added, removed or renamed.

  • The case-pinning backlog is zero (#92). All 651 measurable fixture cases are now run by at least one driver — mutating any one of them reddens a suite. The starting point was 36 measured against apcore-python alone, and the fixes landed in all three SDKs (apcore-python 2f4daad, apcore-typescript 5b3b875, apcore-rust 289c2f8). No SDK defect surfaced in any of them: every newly-real assertion passed against the shipped implementation on its first run, so the contracts these fixtures declare were being met all along and simply were not checked. The last one standing was binding_errors::pipeline_handler_not_supported_rust — a case that exists to pin a Rust-only behaviour, whose driver hardcoded its message fragments as an OR of two literals and asserted no code at all.

  • .github/workflows/case-pinning.yml — a scheduled sweep that fails when a NEW case goes unpinned. check_driver_coverage.py answers does each SDK load this fixture, check_expected_keys_read.py answers does any driver read this expected key; neither answers does any driver run this case, and a case every driver skips leaves both green. The new job mutates each case's expectation and diffs the result against conformance/case_pinning_baseline.json, so the backlog can shrink but cannot grow. Daily rather than per-PR: the sweep runs the real drivers for 651 cases across three SDKs, roughly 20 minutes.


Changed

  • BREAKING: a before_step failure terminates the step and is NOT recoverable (middleware-system). before_step raising and the step body raising previously shared one recovery path, so a value returned from on_step_error after a before_step failure let the pipeline advance past a step whose body never ran. The built-in strategy places acl_check and approval_gate in that sequence, making this a silent authorization bypass reachable from an extension point that carries no authority. The two paths are now separate: on the before_step path the recovery value MUST be discarded, after_step MUST NOT fire, and the step's ignore_errors MUST NOT apply — MiddlewareChainError propagates regardless. apcore-rust already behaved this way and the spec adopted its behaviour; apcore-python and apcore-typescript are changed to match. Pinned by pipeline_step_middleware.jsonbefore_step_failure_recovery_is_discarded, whose driver contract requires asserting that the following step did not execute rather than merely that an error was raised — a raise-only assertion is satisfied by an implementation that honours the recovery and happens to fail later, while the bypass is live.

    The module-level precedent does not transfer, despite the shared vocabulary: a module-level recovery value terminates the call and is the return value, whereas a step-level recovery value resumes a pipeline. Same name, different operation.

  • after_step MUST fire after a recovered step body (middleware-system). A recovered step produced an output and the pipeline continued, so the onion has to close — a middleware that acquired something in before_step was not getting its after_step, which leaks. apcore-python and apcore-typescript already did this; apcore-rust skipped it (pipeline.rs recovery branch jumped past the hook block). Pinned by after_step_fires_after_a_recovered_step, deliberately the opposite case to the one above.

  • Async-callback detection is an SDK-local concern (middleware-system). The rule prescribed inspect.iscoroutinefunction for Python, which misses functools.partial and decorator wrappers; apcore-python gates on inspect.isawaitable() applied to the returned value (Issue #42) and is correct to. Implementations MUST NOT be judged on the mechanism.

  • BREAKING: format is an annotation, not an assertion (type-mapping §11). §11.1 said format "provides semantic validation" and implementations SHOULD validate it, which reads as "a non-conforming value fails". Under the default format-annotation vocabulary of JSON Schema 2020-12 §7.2.1 it does not: a value that does not satisfy its declared format MUST NOT fail validation, and a format the implementation does not recognise MUST pass silently. §11 now states this, and points at pattern / enum for authors who want a binding constraint. Also records that where the SHOULD-level warning is emitted is not uniform — apcore-rust computes format warnings in SchemaValidator::validate_detailed_raw, but its module-invocation path never goes through SchemaValidator at all (the executor's validate_against_schema builds its own validator), so a Rust module call emits no format warning.

  • BREAKING: no type coercion at the module boundary (type-mapping §17.3, rule R5). The boundary MUST NOT coerce, under any host configuration. A coerce_types library knob MAY exist but MUST NOT reach that path and MUST NOT be readable from configuration. "No coercion" is about instance types, not renderings — 4.0 still satisfies integer per §6.1.1. Removes the last references to schema.validation.coerce_types, a key no SDK ever read.

  • BREAKING: self-reference and circular reference are different things (§4.15). The blanket "circular reference detection (A → B → A) MUST throw SCHEMA_CIRCULAR_REF" made every recursive schema unregisterable, contradicting the Recursive Schema Support requirement stated in the same specification. A $ref re-entered after descending through a schema body (properties / items / a combinator) is a recursive data structure and MUST be preserved as a lazy reference; only a $ref$ref chain reaching no schema body MUST raise. RefResolver MUST NOT inline a self-reference, and the conversion layer MUST bind it at validation time rather than widening it to accept-anything.

  • BREAKING: oneOf exclusivity is location-independent (schema-system). The exhaustive-evaluation rule applies inside properties, items, $defs and nested combinators, not only at the document root; a root-level combinator MUST be enforced on the generated native model so every call path observes it.

  • BREAKING: A23 object detection and nullable spelling (algorithms, §4.16). Object detection is now "carries properties and (type absent or type declares object)", resolving the contradiction between §4.16 ("All nested object types MUST set additionalProperties: false") and the A23 pseudo-code (node.type == "object"). The recursion list gains prefixItems, plus definitions / $defs which all three SDKs already walked but the specification never listed. An optional property with no type — including an author-written oneOf / anyOf — is wrapped as {anyOf: [<original>, {type: "null"}]}, never appended to: one nullable spelling, and anyOf because OpenAI structured outputs accepts only that.

  • schema configuration namespace corrected (§4.9). The example declared schema.paths.yaml_schemas and schema.validation.*; the real namespace is root / strategy / max_ref_depth, and schemas/defaults.schema.json declares it additionalProperties: false — so the documented example was a configuration error. Strictness and coercion are properties of the contract, not of the host.

  • pipeline.configure now has a declared field set, and it is four fields wide (#89). schemas/apcore-config.schema.json gains $defs/ConfigurableStepFieldsmatch_modules, ignore_errors, pure, timeout_ms, additionalProperties: false — and configure's value subschema points at it. It was additionalProperties: true, which pinned nothing, and three SDKs accepted three different sets: apcore-python took any attribute of the concrete Step object (hasattr), apcore-typescript a 9-entry map, apcore-rust a 4-name list. An unknown key was a parse-time error in two of them and a warn-and-continue in the third, so an operator's typo silently produced an unconfigured pipeline on one SDK. spec/DECLARATIVE_CONFIG_SPEC.md §4.2 states the set, states that any other key MUST raise PIPELINE_CONFIGURATION_ERROR, and states that snake_case is the wire spelling — an SDK MAY accept an idiomatic alias at its own API boundary but a configuration file carries the canonical spelling only.

  • requires / provides are not configurable, and the specification's own example said otherwise (#89). features/middleware-system.md § Configuration safety shipped an apcore.yaml setting them under configure:, annotated "matching schemas/apcore-config.schema.json". Run through the shipped apcore-python loader, that exact example moved the built-in input_validation step from requires=('module',) to requires=('context',)deleting the upstream dependency module_lookup satisfies, after which strategy construction validates cleanly and the PipelineDependencyError that the same section states as a MUST can never fire for that step. The documented way to exercise the dependency contract was the way to disable it. A step's capability contract belongs to its implementation; the example is replaced with one showing the contract declared on the step class in all three languages, and a configure: block using only the four configurable fields. A configuration file carrying requires/provides is now a startup error, with a migration note in the page.

  • DECLARATIVE_CONFIG_SPEC.md is at spec version 1.1, and no longer calls itself unimplemented. The §4.2 rule above is the normative change. The header also said "Draft for review — not yet implemented" while all three SDKs had shipped the pipeline: section since 0.19.0 — a status line four months stale on a document the schema and three implementations already follow.

  • features/registry-system.md § Multi-version registration described two SDKs wrongly. It said apcore-typescript's register(moduleId, module) "always replaces any prior registration" — it takes the full four-argument signature and rejects a duplicate id through A03 conflict detection with DUPLICATE_MODULE_ID — and called apcore-rust's three-argument descriptor form "a Rust-only signature divergence tracked for cross-language alignment", when register_versioned(name, module, version, metadata) is the spec-shaped four-argument form and has been there all along. Rewritten around the distinction that actually separates the three: all three accept version and metadata, only apcore-python resolves by version. The page also now records that metadata.dependencies reaching the module descriptor is converged behaviour with nothing normative behind it — §12.2's Interface: Registry does not declare register at all — filed as #90.

  • The library-level coercion knob's behaviour is normative when the knob exists (#95, spec v1.12.0). Offering the switch stays a MAY; an SDK that offers one MUST coerce exactly string→integer, string→number, and string→boolean limited to "true" / "false" case-sensitive, and MUST NOT coerce anything else.

    Until now type-mapping.md constrained only where the knob may be used — not on the module-invocation path, not readable from configuration, default off — and said nothing about what it does. Measured through the conformance driver's own path: apcore-rust and apcore-typescript ship a twelve-spelling case-insensitive dialect ("true" | "yes" | "on" | "y" | "t" | "1" and its negatives, validator.rs:443, ported verbatim into TypeScript during #93), while apcore-python coerces no string to a boolean at all. Both were conforming. "0"false is the sharpest edge: R5 makes the number 0 a MUST-reject for boolean, so an SDK accepting the string "0" put two of its own paths on opposite sides of one value.

    The boolean row is capped at JSON's own two literals deliberately. "true" and "false" are the same value written as text; "yes", "on", "y", "t", "1", "0" are shell and INI conventions that belong to whatever parses argv — each one somebody's default rather than anybody's standard.

    conformance/fixtures/schema_validation.json gains six cases and a driver_contract. It had pinned the coercing mode cross-SDK in exactly one case, wrong_type_string_for_integer, on integer — the one axis where all three agreed — which is why the divergence never fired. Four of the six new cases assert a spelling that MUST NOT coerce, because a fixture carrying only "true" is satisfied by an implementation that coerces any non-empty string, which is close to what two SDKs actually shipped.

  • Per-SDK case pinning is at zero on all four scopes (#93). 651 cases × any + each SDK — every one now reddens a driver when its declared expectation is mutated. The backlog was 84 gaps over 75 cases: apcore-rust 32, apcore-typescript 26, apcore-python 26, nearly disjoint, with none missed by all three, which is why the any scope had read clean. Fixed in apcore-python 4557fa7, apcore-typescript 8472096, apcore-rust 321e6f3.

    Three real SDK defects surfaced, each at the moment a fixture's declared value first reached an assertion. apcore-typescript's SchemaValidator(true) did not coerce at all — it called Value.Decode, which applies TypeBox transforms and never converts types, while its own doc comment blocked on "picking a conversion semantics all three can agree on" that the other two had agreed on and shipped. apcore-rust emitted serde's missing field \bindings`where §7.2 fixes the message and the other two comply; its driver had readexpected_messageand discarded it with a comment that the Rust text "may differ". And apcore-rust'sErrorCode::BindingInvalidTarget` was declared, categorised, and raised by nothing — §2.2 requires target-syntax validation at parse time and none was performed, so a target with no colon loaded silently and failed later as an unrelated handler-map miss.

    config_defaults was the headline risk and did not materialise, for a reason worth recording: apcore-rust's driver carried a six-entry allowlist skipping 12 of 18 defaults, justified as "no default in the Rust SDK" — stale since CONFIG_DEFAULTS landed. With it gone all 18 agree. config_key_governance::sdk_reproduces_every_canonical_default is the same property one level up and asserted missing.is_empty() while never reading expected.missing; both layers empty at once is how it stayed invisible.

  • check_case_pinning.py exports CONFORMANCE_SPEC_REPO to every driver process. Relying on the ambient environment meant a driver could validate a different checkout than the one being mutated — tests ran, NoTestsRan stayed quiet, and the report was plausible and wrong. This is the fifth bug found in this tool, and the worst: the earlier ones produced a suspicious zero, this one a believable non-zero against the wrong corpus.

  • http_status is documented as descriptive, not a required SDK surface (#94). §7.5 and §8.2 annotate 47 framework error codes with an http_status, under a heading reading "Implementations MUST define the following error types". Read as a requirement that would be 47 unimplemented MUSTs — no SDK exposes the mapping and none is expected to, and the HTTP-facing downstream family (apcore-a2a-*) references it nowhere, measured at zero hits across all three. The MUST is about defining the error types; the status is metadata about each, like description. §8.2 now says so, and conformance/fixtures/approval_gate.json drops the expected.http_status (403 / 202) that no driver could assert — a fixture may only declare what a driver can check. 499 is an nginx extension and 508 is WebDAV: a gateway author's practical shorthand, not a contract three SDKs should reproduce.

  • The case-pinning gate now asks the per-SDK question, not just "does SOME driver run this case" (#93). The weak question read as zero while every SDK still had its own blind spots: 84 gaps over 75 cases, only 9 shared by two SDKs and none by all three, which is exactly why the total was clean. Worse, fixing toward it produces residue by construction — it stops at the first SDK that turns a case green, and call_chain's six positive cases were fixed in apcore-python during #92 while staying broken in apcore-typescript and apcore-rust for that reason alone. The scheduled job now runs four scopes (any plus each SDK) and conformance/case_pinning_baseline.json is keyed by scope, so a regression names the SDK instead of moving a total. Runtime goes from roughly 20 to 80 minutes, which is why it stays scheduled rather than moving to the PR path.

  • Three cases recorded in case_pinning_allowlist.json as REQUIRED per-SDK skips (#93). A deliberate skip and a forgotten case are indistinguishable from outside, so each entry carries the evidence from the fixture's own text: binding_errors::pipeline_handler_not_supported_rust (apcore-python and apcore-typescript both resolve handler: and have no such error to raise), binding_errors::binding_schema_inference_failed_python (apcore-rust uses an opaque handler-map with no runtime inference — DECLARATIVE_CONFIG_SPEC §4.4 "Rust caveat"), and system_modules_hardening::rust_register_returns_result (the case pins a Result<SysModulesContext, SysModuleError> return neither peer has).

  • Cross-executor rebind is normative, and the deviation clause is withdrawn (#92, spec v1.11.0). features/core-executor.md stated it as SHOULD raise ContextBindingError, with "SDKs that choose to accept silently instead MUST document the deviation prominently" as the escape hatch. All three SDKs raise — apcore-python context.py:152, apcore-typescript context.ts:187, apcore-rust context.rs:765 — so the deviation was permitted for nobody, and it had a real cost: conformance/fixtures/context_create.json expressed it as expected_one_of: [raise, silent_accept], and no driver can assert an alternation without deciding its own branch. All three hardcoded raise and read the alternation only in a comment, so mutating the whole expectation left every suite green. protocol-spec.md §12.2 now states the rule on Interface: Executor with the wire code CONTEXT_BINDING_ERROR; the fixture carries a single expected. No SDK behaviour change — this pins what all three already do.

Fixed

  • schema_hardening_formats.json cited a section that said nothing about format. The fixture and the conformance/README.md row both pointed at PROTOCOL_SPEC §4.15, whose edge-case table covered $ref depth, empty schemas, required: [] and enum with null — and never mentioned format at all, so the only citation pinning the behaviour resolved to nothing. §4.15 gains the two rows (a value failing a recognised format is accepted, warning is SHOULD; an unrecognised format is collected as an annotation and passes silently), and both citations now name type-mapping §11.1 as the authority.

  • §12.8.5.1 described a module_preflight behaviour no SDK provides. It read "if preflight() returns an empty list or is not defined, no module_preflight check is added". The second half is right; the first is not — all three SDKs add a passed: true entry with no warnings for an empty list, and have since the check existed. The two cases are now stated separately.

  • features/middleware-system.md §Pipeline Step Middleware was written without implementation contact. It specified before_step(step_name, ctx, inputs) and a MUST that a non-null return replaces the step's inputs — a calling convention that exists in no SDK, because a Step is execute(ctx) and takes no inputs argument. All three SDKs independently ship before_step(step_name, state) over a single growable PipelineState; three implementations agreeing against one spec sentence is the sentence being wrong. before_step is now documented as an observation hook whose return value carries no meaning. The section also showed pipeline.configure as an array of {name, …} objects — it is an object map keyed by step name, per $defs/PipelineConfig and all three parsers — and documented a pipeline.step_middleware: config section that no SDK has ever parsed.

  • Three SDKs emitted three different wire codes for the same pipeline-config failure. conformance/fixtures/pipeline_failfast_config.json asserted error_type: "ConfigurationError" — a class name that all three happen to share — and so reported green while Python emitted PIPELINE_CONFIGURATION_ERROR, TypeScript PIPELINE_CONFIG_INVALID and Rust CONFIGURATION_ERROR (a string in no registry). The fixture now asserts the wire code. The canonical code is PIPELINE_CONFIGURATION_ERROR; features/error-system.md additionally had two rows describing the same event, and now states the deliberate layer boundary: the strategy API raises STEP_NOT_FOUND / PIPELINE_STEP_NOT_FOUND, and the pipeline: config layer re-classifies that miss as PIPELINE_CONFIGURATION_ERROR so the message can name the offending config key.

  • validate_input is not a step. The built-in steps are context_creation, call_chain_guard, module_lookup, acl_check, approval_gate, middleware_before, input_validation, execute, output_validation, middleware_after, return_result. Three fixtures (pipeline_hardening, pipeline_failfast_config, pipeline_step_middleware) referenced validate_input, which exists in no SDK. In pipeline_failfast_config this was load-bearing: the fail-fast case relied on one valid key and one invalid one, so with both invalid it would have raised on the first and never reached the assertion it was written to make.

  • features/observability.md StorageBackend examples stored bytes; the contract is JSON objects. All three Redis examples used bytes / Uint8Array / &[u8] while the actual signatures are dict / Record<string, unknown> / serde_json::Value, and all three collectors index into the returned value — so a backend copied from the docs breaks at the collector, not at the backend. The examples now serialize at the boundary, the value type is stated normatively, and the Rust example gains the #[async_trait] and Debug the real trait requires (it could not have compiled).

  • OverridesStore was specified with a per-key surface no SDK implements. features/system-modules.md required set(key, value) / get(key) / get_all() / delete(key); all three SDKs ship the whole-map load() / save(mapping) of decision D-47, and the system.control.* paths do a read-modify-write. Spec and conformance/fixtures/overrides_store.json aligned to D-47.

  • conformance/fixtures/async_task_evolution.json reaper case was arithmetically unreachable. With now_timestamp: 1700003000.0 the cutoff expired both tasks, making the asserted remaining_task_ids: ["fresh-task-001"] impossible; apcore-typescript had silently substituted its own stableNow to get the driver to pass. Corrected to 1700002000.0.

  • conformance/fixtures/event_naming.json still required removed behavior. Two cases asserted the v0.21.x legacy dual-emission that v0.22.0 removed (apcore#78). Replaced with the inverse assertion — legacy names MUST NOT be emitted — so the removal is pinned rather than merely un-tested. The health-threshold case also asserted an exact p99 of 6000.0; p99 is a bucketed estimate and Rust's default bucket boundaries have no 6.0s edge, so it now asserts a lower bound.

  • schemas/apcore-config.schema.json rejected the specification's own canonical config. ExtensionsConfig declared additionalProperties: false as a sibling of oneOf with no sibling properties, so under Draft 2020-12 it saw no annotations from the branches and rejected every extensions key — including root, a MUST field. Replaced with unevaluatedProperties: false, which is exactly the rule type-mapping §17.2/§10 now states. The root object also omitted four documented namespaces (bindings, sys_modules, stream, obs) and ExecutorConfig omitted default_timeout / global_timeout, all of which are normatively required; schema.validation.* was still declared; max_ref_depth allowed 128 against the §9.1.1 range of 1..100.

  • observability.{tracing,metrics}.enabled default corrected to false. apcore-config.schema.json and the §9.1 example said true; defaults.schema.json and all three SDKs use false. Aligned to the implementations.

  • §9.1.1 declared two configuration keys that exist nowhere. observability.enabled and observability.exporter appear in neither the §9.1 example nor either JSON Schema, both of which are additionalProperties: false — so a config following that MUST was rejected. Replaced with the five nested keys actually defined.

  • A05 resolve_ref pseudocode still threw on every revisited $ref in both PROTOCOL_SPEC §4.11 and ALGORITHMS, contradicting the new §4.15 and making the spec's own TreeNode example raise. Rewritten with the visited_refs root-alias seed, the depth cap and the from_ref_chain discriminator that all three SDKs implement. The §4.15 edge-case table also still required SCHEMA_CIRCULAR_REF for depth exhaustion; it now requires SCHEMA_MAX_DEPTH_EXCEEDED, which is added to the §8 error-code registry, the retryability table and the exception hierarchy along with SCHEMA_UNION_NO_MATCH / SCHEMA_UNION_AMBIGUOUS (asserted by fixtures but previously absent from both registries).

  • Stale propagation of the schema-hardening change. docs/features/schema-system.md Data Flow still said RefResolver inlines all $ref targets; its "Semantic Format Mapping" section still demanded assertive format checking; docs/guides/troubleshooting.md gave the now-legal self-reference as the SCHEMA_CIRCULAR_REF trigger; docs/guides/schema-definition.md promised native-type format mappings no SDK performs.

  • docs/spec/conformance.md §8.1 fixture inventory was stale by 15 fixtures and 267 cases (370/43 → 637/58); regenerated from disk. conformance/README.md listed A23 as a coverage gap in the same commit that added its fixture, said "Four fixtures" for what is now seven non-standard patterns, omitted the three new fixtures' expected_valid / expected_features shapes and the root-level driver_contract convention, and cited PROTOCOL_SPEC §6.2/§6.6 where DECLARATIVE_CONFIG_SPEC was meant.

  • ~120 non-runnable code examples across docs/features/, docs/guides/ and docs/getting-started.md. Whole sections documented a client surface that exists in no SDK (configure_observability, use_step_middleware, client.use(mw, allow_duplicate=…), RedisTaskStore), written identically into all three language tabs — mutually consistent and mutually wrong, which is why cross-language review never caught them. Also fixed: ~25 apcore-js/<subpath> imports (the package exports only . and ./context-keys), 11 imports of a nonexistent apcore npm package, Context.create() called with an options object or the wrong positional slot in all three languages, 49 Rust public fields called as methods, nonexistent ErrorCode variants, 7 Python imports from module paths that do not exist, TypeScript type-only exports instantiated with new, Executor.call awaited in Python where it is synchronous, and a getting-started.md Rust tab that could not compile.

  • RFC 2119 keywords lowercased in §13 Compatibility Rules and the Phase A approval conformance requirement.

  • ExecutionPolicy status said the TypeScript and Rust rollout was in progress; both shipped in 0.26.0.

  • The conformance inventory's case counts were unverified and 8 of 60 had drifted. docs/spec/conformance.md §8.1 prints a per-fixture case count and a Total; CI checked only that each fixture name appeared. config_key_governance said 4 against 6, redaction_config 4 against 7, pipeline_step_middleware 6 against 9, event_naming 8 against 7 — drift in both directions — and the Total read 646 against an actual 657. Counts corrected, and the CI step now verifies each row and the Total against the fixtures. Injection-tested in both directions: altering a row and altering the Total each turn the step red. A number nobody checks reads as coverage in every review and every inventory built from it, which is the same failure the expected-keys guard exists to catch, one level up.


  • Two fixture cases stated expectations no driver could assert (#92). identity_system's identity_propagates_to_child_context had expected: "child.identity === parent.identity" — prose in a value slot. A driver cannot compare that to anything, so every driver hardcoded the comparison and the fixture value was decoration; it now states four checkable fields. context_create's rebind case is covered above. Both were found by check_case_pinning.py, which reported them as run by nobody.

  • Driver contracts added to the three fixtures whose declared values reached no assertion (#92). error_codes, version_negotiation and call_chain now state, in the fixture itself: assert the WIRE CODE not the class name; branching on "expected_error" in case tests that the key exists, not what it says; an expectation the driver does not recognise MUST be a hard failure rather than a skipped branch; and a positive case whose expectation is the sentinel "ok" MUST assert an observable post-condition, because "the call did not raise" is also satisfied by an implementation that does nothing. call_chain is cited as the model — its negative half maps the wire code through a table before asserting, which is exactly why its negative cases are pinned and its positive ones are not. The same file contains both the right and the wrong pattern.

  • check_case_pinning.py had three bugs that manufactured its own findings, each an instance of the class it exists to detect. mutate({}) returned {}, so a case expecting an empty object could never go red. cargo test -- <name> filtered by test name, matched nothing, exited 0, and a run that executed no tests was read as "nothing went red" — every Rust verdict in the first per-SDK sweep came from that. Fixing it introduced the third: a combined --test A --test B -- filter applies the filter to both binaries, silently filtering out the real driver. The tool now raises NoTestsRan instead of treating a no-op run as green. Corrected sweep: 651 cases mutated, 23 unpinned in 5 fixtures, down from a claimed 25 in 6 — dependency_version_constraints turned out to be fully pinned by apcore-rust, a finding the broken invocation had buried.

[0.26.0] - 2026-07-13

PROTOCOL_SPEC bumped to v1.9.0-draft.

Added

  • Execution Policy — first-class external governance (PROTOCOL_SPEC §7.9, #76). New normative section defining a declarative, execution-time policy layer that overrides a module's requires_approval / destructive annotations independently of how the module was registered. Normative rules: attach at the Executor and consult from the Approval Gate (Step 5); pattern rules matched with ACL wildcard semantics (A08) and specificity scoring (A10), with the more-restrictive rule winning ties; policy overrides take precedence over declared annotations; gate_destructive opt-in resolves the destructive→approval footgun; the handler-visible ApprovalRequest.annotations carries effective values (preserving the §7.3 "requires_approval guaranteed true" contract); fail-loud principle — a needs-approval-but-no-handler case MUST warn (default) or fail closed under strict, and policy documents MUST reject unknown keys. Adds Conformance Level 4 (Governance). Reference pilot: apcore-python 0.26.0; TS/Rust rollout in progress.

  • Governance events on the event bus (PROTOCOL_SPEC §9.16.2, #77). Three new canonical event types make the ACL → policy → approval chain observable: apcore.approval.decision (every adjudication incl. strict fail-closed; info for approved/pending, warn for rejected/timeout), apcore.policy.override (when a policy changes effective governance), and apcore.acl.denied (on ACL denial). All are emitted only when an event emitter is configured, are best-effort side channels, and are suppressed on a skipped gate / dry-run preflight.

  • Documented six already-emitted events that were absent from the §9.16.2 canonical table: apcore.stream.post_validation_failed, apcore.registry.module_load_failed, apcore.circuit.opened / apcore.circuit.closed, apcore.subscriber.circuit_opened / apcore.subscriber.circuit_closed, plus the dead-letter apcore.event.delivery_failed. The table previously claimed to list "all events emitted by apcore SDKs" but omitted these. docs/features/event-system.md mirrors the additions.

Notes

  • SDK-side companion work (not spec changes), all tracked in their own changelogs:
    • #76/#77 pilots landed in apcore-python, apcore-typescript, and apcore-rust at 0.26.0 (ExecutionPolicy + the three governance events + the no-handler-skip → fail-loud flip).
    • #78 (event hygiene): apcore-python and apcore-rust dropped the legacy unprefixed dual-emission (module_registered / error_threshold_exceeded / …) to comply with the v0.22.0 canonical-only MUST; apcore-typescript was already compliant. apcore-rust additionally now actually emits apcore.stream.post_validation_failed (previously a comment-only stub).

[0.25.0] - 2026-06-22

Added

  • Config-driven ACL discovery — acl.root activation (#74, D-64). acl.root was a dead config key in all three SDKs: registered (hard-required in Rust) and validated, but never consumed to load an ACL. A new ACL.discover(config) resolves acl.root and attaches an ACL only when the path exists, wired automatically by the APCore bootstrap. acl.root is a directory by convention — discovery loads <root>/global_acl.yaml (PROTOCOL_SPEC §3.1) — and MAY also point directly at a YAML file. Critical non-breaking invariant: a missing acl.root path attaches no ACL (preserving today's no-enforcement default) and MUST NOT synthesize an empty default_effect: deny ACL. Discovery is skipped when the caller supplies their own Executor, so an explicitly-wired ACL is never clobbered. New conformance fixture conformance/fixtures/acl_root_discovery.json locks the cross-language contract; the ACL.discover contract is documented in docs/features/acl-system.md. Implemented across apcore-python, apcore-typescript, and apcore-rust.

  • API Surface & Naming Conventions specification (docs/spec/api-surface-conventions.md). Normative rules for when a symbol is public API, why cross-boundary contract members MUST NOT carry a private name, and the per-language visibility-vs-discoverability idioms. Includes §8 Cross-SDK Surface Equivalence: the structural divergences that are by-design (error-taxonomy shape — per-type classes vs. a single ErrorCode enum; namespace depth — sub-package vs. root-flattened) and the reachability rule that still flags a genuine break (a public symbol reachable only through an internal implementation module), plus auditing guidance so these no longer surface as false-positive findings.

  • RFC — include: cross-file configuration composition (docs/spec/rfc-config-include.md, Proposed) + decision D-65 (#75). Design-first contract for a top-level include: key: file-path list resolved relative to the declaring file, pre-validation expansion into one merged mapping, precedence A < B < local (deep-merge mappings, replace scalars/lists), recursive includes with cycle detection (CONFIG_INCLUDE_CYCLE). YAGNI exclusions: no globs, remote URLs, conditional includes, or list-element merging. No SDK implementation yet — awaiting maintainer ratification.

Changed

  • acl.root default unified to ./acl across all three SDKs (#74, D-64). Rust previously hard-required acl.root and rejected its omission with CONFIG_INVALID; it now defaults to ./acl like Python and TypeScript, so a config omitting the key is valid in every SDK. Removes a cross-language divergence where the same apcore.yaml passed in Python/TS but failed validation in Rust.

  • docs/features/acl-system.md — added the ACL.discover contract (inputs, ordered side-effects, the missing-path danger admonition, and cross-language usage showing acl.root in apcore.yaml with automatic attach via APCore).

Notes

  • Decision log: D-64 (acl.root activation) recorded and implemented; D-65 (include:) opened as a design-first RFC awaiting ratification before any code. Front-matter status is now 51 resolved / 8 open.
  • SDK-side companion fix (not a spec change): apcore-python resolved #30 by re-exporting the registry module-id constants (MAX_MODULE_ID_LENGTH, RESERVED_WORDS, REGISTRY_EVENTS, EPHEMERAL_NAMESPACE_PREFIX, DEFAULT_MODULE_VERSION, MODULE_ID_PATTERN) from public paths — exactly the class of break the new §8 reachability rule codifies. Tracked in the apcore-python changelog.

[0.24.0] - 2026-06-12

Added

  • Per-instance ToggleState isolation (#71). Each APCore instance now owns one ToggleState, injected into both the toggle module (write path) and the pipeline lookup step BuiltinModuleLookup (read path). Disabling a module on one APCore instance no longer affects another instance in the same process — closing the isolation gap for multi-tenant servers and test bleed. The process-global ToggleState is retained only as a fallback for the free is_module_disabled / isModuleDisabled function (callers that hold no instance handle). A-D-12 is re-scoped from "process-global, survives reload" to "isolated to the APCore instance, survives reload of that instance" (docs/features/system-modules.md). New conformance fixture conformance/fixtures/toggle_state_isolation.json locks the cross-language contract (cross-instance isolation + reload survival). Implemented across apcore-python, apcore-typescript, and apcore-rust.

  • Canonical AI-agent tool-governance ACL artifact + fixture (#72). New runnable reference examples/acl/agent-tool-governance.yaml — a default_effect: deny policy that scopes tool access by caller pattern + identity roles + max_call_depth: @externalexecutor.*.read only; agent.* + roles: [reader]executor.*.read / executor.*.query capped at max_call_depth: 3; agent.* + roles: [data_admin]data.export / executor.*.delete (uncapped). New conformance fixture conformance/fixtures/acl_agent_scoping.json freezes the scenario as a cross-language contract (19 cases: the @external < reader < data_admin privilege gradient, the inclusive depth boundary — depth 3 allowed / 4 denied, role separation, and missing-identity deny). New guide section "9.4 AI Agent Tool Governance" in docs/guides/acl-configuration.md. Additive and non-normative — no protocol-spec.md change; the ACL engine already supports conditions: {roles, max_call_depth}. Framework integrations (django-apcore, fastapi-apcore, …) vendor the artifact and verify identical decisions.

Changed

  • docs/features/system-modules.md — toggle-state contract re-scoped from process-global to per-instance (#71). The "Disabled modules" note, the system.control.toggle_feature postconditions, and the is_module_disabled Contract block now state that toggle state is isolated to the owning APCore instance (with the standalone free function reading the global fallback). No public method-signature changes.

Fixed

  • docs/features/event-system.mdEventEmitter.emit Contract internal contradiction resolved. The ### Inputs bullet said event_type "MUST NOT be empty" while the same block's ### Errors ("No errors raised to the caller. emit is fire-and-forget") and ### Properties ("never raises") sections — which all three SDKs implement — mandate never-raising. Surfaced by a cross-language tester --category protocol run (a green TypeScript placeholder test mislabeled SKIP had been miscounted as enforcing the rule). The MUST on the caller is preserved, reframed as a caller precondition, not a validated rejection, matching the Errors/Properties sections and the unanimous SDK behavior. No public surface change, no normative weakening, version stays 0.24.0. The corresponding apcore-typescript placeholder test was converted to a real it.skip() so it stops registering as a false-positive pass.

Notes

  • #70 (default AI error-recovery metadata) confirmed complete across all three SDKs. user_fixable-by-code resolution and the ai_guidance defaults ship in apcore-python / apcore-typescript / apcore-rust and pass error_recovery_metadata.json (the 0.23.0 "apcore-typescript / apcore-rust pending" note is superseded). Remaining follow-ups are tracked outside this changelog entry: the protocol-spec.md §8 user_fixable alignment note (requires a linked issue + dual maintainer review per repo policy) and the downstream apcore-mcp floor bump to apcore>=0.23.0.

[0.23.0] - 2026-06-10

Added

  • Conformance fixture error_recovery_metadata.json — default AI error-recovery metadata per error code (#70). Defines the cross-language contract for retryable + user_fixable defaults: user_fixable is resolved from the error code (caller-fixable-by-input = true; governance/system/structural/transient = false); codes whose user_fixable is null are left for the module author (e.g. MODULE_EXECUTE_ERROR). All SDKs MUST produce these defaults when an error is constructed without overrides. ai_guidance text is not pinned (human-readable, may carry runtime context). Registered in conformance/README.md. Implemented in apcore-python 0.23.0; apcore-typescript / apcore-rust pending.

Changed

  • error_serialization.json omits_null_optionals case now uses a real policy-free code (CONTEXT_BINDING_ERROR) (#70). Real codes such as GENERAL_INVALID_INPUT now carry a default user_fixable, so the null-optional-omission case must use a code absent from the user_fixable policy — and a real ErrorCode variant, since Rust models codes as a closed enum — to keep exercising sparse-output omission across all SDKs.

Fixed

These are spec-consistency clarifications surfaced by a cross-language sync audit. They resolve internal contradictions/gaps in the feature specs so the three SDKs have a single source of truth to converge on; no public surface changes, version stays 0.23.0.

  • Error fingerprint definition de-duplicated (docs/features/observability.md). The "Error fingerprinting" section defined the digest as SHA-256(error_code:top_frame_hash:sanitized_message) while §1.4 defined it as SHA-256(error_code:module_id:normalized_message). A top_frame_hash (stack frame file/function/line) is not portable across Python/TypeScript/Rust, so it can never produce an equal cross-language fingerprint. §1.4 (the 3-part, module_id-based form) is now the single authoritative definition; top_frame_hash is demoted to an optional language-local diagnostic field that MUST NOT influence the shared fingerprint, and the normalization is pinned to the exact five-step §1.4 algorithm (no extra hex-collapsing step). Code follow-up: apcore-python must drop the top-frame component and the extra normalization step to match TS/Rust.
  • Event-emitter overflow behavior is now normative (docs/features/event-system.md). The emit() contract previously left buffer-overflow behavior undefined, allowing a bounded maxPending queue to silently drop deliveries (bypassing the dead-letter path). emit() now MUST NOT silently discard an accepted event: a bounded buffer's overflow MUST either apply backpressure or fail through the dead-letter path (apcore.event.delivery_failed, reason: "pending_overflow"). Code follow-up: apcore-typescript must route maxPending overflow through the DLQ instead of console.warn.
  • Config.validate() contract added (docs/features/config-bus.md). The spec previously defined no required-field set or value constraints, so each SDK enforced a different subset (same config, three verdicts). Added a normative Contract: Config.validate with the canonical required fields, value constraints, and namespace-mode schema/strict rules, anchored to the reference SDK. Code follow-up: apcore-rust and apcore-typescript align up to the full set.
  • Conformance fixture multi_module_discovery.json::full_id_grammar_valid corrected. A single-class input expected the appended id executor.math.arithmetic.addition, contradicting the single-class identity guarantee (and the single_class_id_unchanged case in the same fixture). Now expects the bare executor.math.arithmetic; verified green in all three SDKs.
  • Contract: guard_call_chain ### Inputs corrected (docs/features/call-chain-guard.md). The Contract block listed a context (Context) input and named the limit params max_depth / max_repeat, contradicting the function signature defined earlier in the same file and implemented identically across all three SDKs: guard_call_chain(module_id, call_chain, *, max_call_depth, max_module_repeat). The block now lists call_chain (not context) and the correct keyword names, making the spec internally consistent; the pure property note now references call_chain rather than "context state". No public surface changes; surfaced by a cross-language tester --category protocol run.

[0.22.0] - 2026-05-27

Changed

  • 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:

    • 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.
    • 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.

    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.

    Rust TraceParent struct gains a tracestate: Vec<(String, String)> field to align with Python/TS shape; the redundant ContextBuilder::tracestate() setter is removed.

    Two new normative sections clarify distributed semantics:

    • §"Contract: Distributed cancellation" — cancel_token is local-only; cross-process cancellation MUST go through out-of-band channels (e.g., AsyncTaskStore task_id lookup).
    • §"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.

    Conformance fixture context_create.json validates cross-SDK parameter parity, removal of executor/caller_id, idempotent same-executor rebinding, and cross-executor conflict behavior.

  • Renumbered decision-log entries D-17–D-22 → D-58–D-63 to resolve a duplicate-ID collision with the v0.22.0 executor/async hardening decisions (which retain D-17–D-22). See docs/spec/2026-05-decision-log.md.

Added

  • 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).

  • Decision D-18 — cancel() MUST be a real interrupt across SDKs (docs/features/async-tasks.md §Cancellation Integration). Cooperative-flag-only cancellation is non-conforming. In TypeScript specifically, CancelToken MUST be backed by an AbortController and MUST expose signal: AbortSignal on Context so modules using fetch / setTimeout / Web Streams participate in real abort. Closes the cross-SDK divergence where Python asyncio.Task.cancel() and Rust tokio handle.abort() interrupted in-flight work but the TS flag-only path silently completed module side effects. Found via finding A-D-AT-02.

  • Decision D-19 — call_with_trace / callWithTrace shares call() error semantics (docs/features/core-executor.md §Trace Variants). The trace variant MUST run the same on_error middleware chain, apply the same cancellation short-circuit (D-20), and apply the same MiddlewareChainError unwrap (D-22). The trace is the observable record of execution — including any middleware recovery — not a sanitized projection. Closes the divergence where TS+Rust call_with_trace rethrew unconditionally while Python ran on_error recovery. Found via finding A-D-EXEC-004.

  • Decision D-20 — Cancellation short-circuits the on_error chain (docs/features/core-executor.md §Cancellation Short-Circuit). ExecutionCancelledError MUST be detected after pipeline-error unwrap and propagated directly, bypassing on_error. Rationale: cancellation is a caller-driven request to stop, not a recoverable failure; allowing on_error middleware to observe it lets logging middleware swallow it or retry middleware reissue a RetrySignal that restarts the loop. Closes the gap in apcore-rust where missing short-circuit allowed middleware to recover cancellation. Found via finding A-D-EXEC-003.

  • Decision D-21 — Cancel token MUST be checked at two pipeline points (docs/features/core-executor.md §Cancel Token Mid-Pipeline Check). The pipeline MUST observe cancel_token cancellation at Step 2 (Call-Chain Guard) and again at Step 8 (Execute), in addition to honoring it inside module.execute() itself. Single-check implementations leak compute (the pipeline runs ACL/middleware/validation even though the caller has already cancelled) and are non-conforming. Closes the divergence where TS checked at step 2 but Python+Rust only checked at step 8 (and Rust missed step 8 entirely). Found via finding A-D-EXEC-002.

  • Decision D-22 — MiddlewareChainError MUST be unwrapped before propagation (docs/features/core-executor.md §Error Unwrap Rule). When a middleware (before / after / on_error) raises a domain-typed error like ApprovalDeniedError, the chain machinery may wrap it in MiddlewareChainError for diagnostics. The executor MUST unwrap before propagating, surfacing the original typed cause. SDKs MUST NOT replace the cause with a generic ModuleExecuteError. Closes the gap where Python wrapped to ModuleExecuteError while TS+Rust unwrapped — breaking MCP/A2A bridges that key off the typed error. Found via finding A-D-EXEC-005.

  • Decision D-23 — get_status / list_tasks MUST return shallow copies in every SDK (docs/features/async-tasks.md §Contract: AsyncTaskManager.get_status). Closes the contract contradiction where the prior spec said "the live dataclass reference (Python); callers MUST NOT rely on mutation" while finding A-D-AT-06 mandated copies. Python now returns dataclasses.replace(info), TypeScript returns { ...info }, Rust returns a clone — uniform mutation-safety contract across all three SDKs. Found while resolving A-D-AT-06.

  • docs/features/registry-system.md — Registration Ordering Invariants now explicitly apply to every register path. Added a !!! warning "Applies to every registration path" admonition clarifying the invariants apply uniformly to the public register() API, internal helpers (register_internal used by sys-modules), and discovery-driven paths (discover(), register_discovered, hot-reload). SDKs MUST NOT create per-path exceptions; if a discover-time on_load callback needs to enumerate sibling modules, the callback MUST be re-shaped as a post-discover hook rather than as grounds for an early-visibility carveout. Closes implementation drift where apcore-python and apcore-typescript implemented deferred-publish on register() but inserted into the visible map BEFORE on_load on the discover path. Found via findings A-D-REG-003 and A-D-REG-004.

Fixed

  • §9.16.2 Canonical Core Event Types table aligned with v0.22.0 #36 rename. docs/spec/protocol-spec.md §9.16 still listed the pre-v0.22.0 canonical names apcore.module.registered / apcore.module.unregistered (subsystem module) and apcore.error.threshold_exceeded / apcore.latency.threshold_exceeded (categories error / latency as the subsystem segment, which violates the apcore.<subsystem>.<event> convention). The v0.22.0 rename had shipped only in docs/features/event-system.md (legacy-aliases table + canonical-events table), leaving the normative spec table contradicting the feature spec, the conformance fixture event_naming.json, the registry-system / rfc-ephemeral-modules / system-modules docs, and all three SDKs' v0.22.0 behavior. Updated rows 6035-6036 to apcore.registry.module_registered / apcore.registry.module_unregistered and rows 6040-6041 to apcore.health.error_threshold_exceeded / apcore.health.latency_threshold_exceeded; updated the §9.16.1 example row; rewrote the §9.16.2 preamble + collision-resolution note to distinguish the v0.18.0 short-form removals (Cohort A) from the v0.22.0 subsystem-segment renames (Cohort B).
  • User-facing examples no longer subscribe to v0.22.0-removed legacy event names. docs/features/apcore-client.md (Python / TypeScript / Rust Production Setup tabs and the event_type parameter example), docs/features/event-system.md (three Subscriber examples), docs/features/observability.md ("Events emitted" table), and docs/features/system-modules.md (Configuration YAML comments) all referenced apcore.error.threshold_exceeded / apcore.latency.threshold_exceeded. Replaced with the v0.22.0 canonical apcore.health.error_threshold_exceeded / apcore.health.latency_threshold_exceeded. The legacy names remain documented in the event-system.md rename table for historical reference; no other doc still teaches users to subscribe to the removed names. Found via /apcore-skills:sync --scope core (findings B-001 through B-004).
  • Cross-language doc/guide consistency corrections (second /apcore-skills:sync --scope core pass). docs/guides/testing-modules.md — removed the forbidden executor= argument from both the Python and TypeScript Context.create() examples; executor self-binds at pipeline entry per D-24 / §"Contract: Executor binding to Context" and is not a create() parameter. docs/features/event-system.md — past-tensed the legacy-event dual-emission rule: dual-emission applied through v0.21.x only and ended at v0.22.0, where implementations emit the canonical names exclusively (aligns with docs/spec/protocol-spec.md §9.16). docs/features/registry-system.md — added the missing await to the async TypeScript registry.register() example. docs/features/middleware-system.md — corrected the stale note claiming TypeScript catches after() errors per-hook; all three SDKs fail-fast on the first error.
  • Call / stream Contract blocks now cite the registered error code SCHEMA_VALIDATION_ERROR. docs/features/apcore-client.md (the call and stream Contract ### Errors blocks) and docs/features/streaming.md (the Module.stream Contract) declared SchemaValidationError(code=SCHEMA_VALIDATION_FAILED) for input-schema validation failures. SCHEMA_VALIDATION_FAILED is not the registered code for the public call/stream surface — the canonical code per docs/spec/protocol-spec.md §8.2 / §4.14 and conformance T04-005 / T06-006 is SCHEMA_VALIDATION_ERROR, which the Python and Rust SDKs emit. Corrected all three Contract-block citations. The _FAILED form remains intentionally documented as a validation-outcome alias in docs/features/error-system.md (Schema Edge-Case Errors table) and on the raise-on-failure validate_input / validate_output / validate_or_error entry points in docs/features/schema-system.md; those are left unchanged. Found via /apcore-skills:sync --scope core review (finding B-001).
  • docs/features/error-system.md — reserved-error-code-prefix list de-duplicated to a single canonical set of 14. The requirements section (§"Error Code Registry") and the ErrorCodeRegistry section carried two contradicting reserved-prefix lists: the former still listed the 0.18-retired ERROR_FORMATTER_ / EXECUTION_ / RELOAD_ / TASK_ prefixes (plus an inconsistent count) while the latter listed only the 14 common-core prefixes. Both lists now declare the identical 14 (ACL_, APPROVAL_, BINDING_, CALL_, CIRCULAR_, CONFIG_, DEPENDENCY_, ERROR_CODE_, FUNC_, GENERAL_, MIDDLEWARE_, MODULE_, SCHEMA_, VERSION_). Added a normative clarification that one-off framework codes outside these prefixes (CIRCUIT_BREAKER_OPEN, CONTEXT_BINDING_ERROR, STREAMING_INTERFACE_MISMATCH, STRATEGY_NOT_FOUND, PIPELINE_*, STEP_*, RELOAD_FAILED, EXECUTION_CANCELLED, TASK_LIMIT_EXCEEDED) are protected by exact-code collision detection rather than prefix reservation. SDKs (apcore-python, apcore-rust) that had drifted to broader prefix sets are realigned to the 14 in their respective repos. Found via /apcore-skills:sync --scope core (findings B-001, A-D-006).
  • docs/features/context-object.md — repaired malformed Rust fenced code block. The canonical Context::create() example had an unclosed Identity::new(...) call and two stray ```rust / ``` fences mid-block, causing the 6-parameter Context::create example to drop out of the rendered MkDocs page. The block is now one continuous, complete Rust example. Found via /apcore-skills:sync --scope core (finding B-002).
  • docs/features/apcore-client.md — Global Singleton scoped to Python-only. The §"Global Singleton" SHOULD statement claimed both Python and TypeScript provide module-level convenience functions (apcore.call()); in practice only the Python SDK does. TypeScript is now grouped with Rust as explicit-instances-only, matching the shipped SDKs. Found via /apcore-skills:sync --scope core (finding B-004).

Added

  • §9.9.5 Reserved Namespace Query — new normative API requirement (#60). All SDKs MUST expose a public, read-only query API returning the set of reserved top-level namespace names (apcore, _config at minimum). The query API is the single source of truth used by register_namespace to enforce CONFIG_NAMESPACE_RESERVED (§9.5.1 rules 3 and 4). Intended for third-party consumers (custom CLIs, framework integrations) that accept user-supplied namespace names and want fail-fast pre-validation. Class-level / module-level access (no Config instance required). Cross-language examples added for Python (Config.reserved_namespaces()), TypeScript (Config.reservedNamespaces), and Rust (Config::reserved_namespaces()).
  • docs/features/event-system.md — Event Delivery Semantics section (#61). Normative cross-subscriber delivery contract: every subscriber type (built-in and user-registered) MUST accept a retry block (max_attempts / initial_backoff_ms / max_backoff_ms / backoff_multiplier) governing retry on transient delivery failure; permanent failure MUST emit apcore.event.delivery_failed (with full payload schema) which itself MUST NOT be retried; subscribe() SHOULD accept an optional on_failure callback. New optional id field on every subscriber config surfaces a stable subscriber_id in DLQ events. The a2a subscriber skill_id is now configurable (default "apevo.event_receiver"). The WebhookSubscriber vs A2ASubscriber comparison table is updated: both now apply the unified retry policy. Discovered during apcore-a2a upgrade — closes the silent-drop gap for in-process subscribers that previously had no retry / DLQ path.
  • docs/features/streaming.md — Streaming Module Interface section (#62). Explicit StreamingModule interface replaces duck-typing: Python @runtime_checkable Protocol with isinstance detection; TypeScript interface + Symbol.for("apcore.streaming") marker + isStreamingModule type-narrowing helper (transitional fallback to method-presence detection with a one-shot deprecation log; marker becomes MUST at next major); Rust trait StreamingModule: Module accessed via Module::as_streaming() -> Option<&dyn StreamingModule>, coexisting with the existing Module::stream() -> Option<ChunkStream> (the two paths MUST stay consistent per module). Defines StreamingInterfaceError raised at module-load time when a declared-streaming module's signature does not match the interface. Adapter / bridge code (apcore-a2a, apcore-mcp) MUST use the standard detection mechanism, not bare hasattr / typeof checks.
  • docs/features/context-object.md — Typed Access via ContextKey[T] section (#63). Promotes the existing ContextKey[T] API (design-context-annotations-acl.md §1.4) as the recommended pattern for stable, schema-bearing state on context.data. Cross-language examples with the correct key-anchored API (KEY.set(ctx, value) / KEY.get(ctx, default) / KEY.exists(ctx) / KEY.delete(ctx) / KEY.scoped(suffix)) for Python / TypeScript / Rust. Aligns the third-party namespace rule with the existing ext.* prefix mandated by middleware-hardening §1.1. Framework-reserved key list sourced from spec §1.5 (TRACING_SPANS / TRACING_SAMPLED / METRICS_STARTS / LOGGING_START / REDACTED_OUTPUT / RETRY_COUNT_BASE). No runtime behavior change — purely a discoverability / best-practice elevation.
  • docs/features/middleware-system.md — Duplicate Middleware Detection section (#64). Normative SHOULD that SDKs detect prior registration of a middleware sharing the same identity (Python: f"{module}.{qualname}"; TS: "<module-specifier>:<ClassName>" with constructor-name fallback; Rust: std::any::type_name::<T>()) and emit a WARNING-level log naming both registration sites. Detection MUST be non-blocking and MUST NOT mutate registration order. Per-registration allow_duplicate flag SHOULD be available for intentional stacking; custom identity_key (vendor-prefixed; apcore.* reserved) lets the same class be registered for distinct purposes without triggering the warning.
  • docs/features/registry-system.md — Registration Ordering Invariants section (#65). Strong-guarantee invariant: a module MUST NOT become visible to discovery APIs (get / list / get_definition) until all on_load callbacks have completed successfully. On callback failure the module MUST NOT become visible and the registry MUST emit apcore.registry.module_load_failed with {module_id, callback_name, error_type, error_message, timestamp}. The existing Contract: Registry.register side-effects ordering is updated to a deferred-publish pattern: reserve in-flight slot under registry lock → release lock → run on_load under per-module init lock → atomically publish or roll back. Callbacks for distinct modules MAY still run concurrently.
  • conformance/fixtures/event_delivery_semantics.json (#61) — 4 cross-language test cases: retry succeeds before exhaustion (verifies attempt count + backoff delays), permanent failure emits apcore.event.delivery_failed with full normative payload, DLQ-subscriber failure is not re-retried (no infinite loop), SDK-generated subscriber_id when config omits id.
  • conformance/fixtures/registry_load_ordering.json (#65) — 4 cross-language test cases: visibility only after successful on_load, callback failure blocks visibility and emits apcore.registry.module_load_failed, concurrent same-ID rejected with DUPLICATE_MODULE_ID, concurrent distinct-IDs run in parallel (wall-clock assertion).
  • conformance/fixtures/schema_content_hash.json (A-D-037) — 5 cross-language schema content-hash canonicalization fixtures designed to expose canonicalization divergence: float rendering (1.0), non-ASCII Unicode keys/values, large integers beyond IEEE-754 exact range, unsorted nested object keys, and a baseline control. No expected hash is recorded — each SDK computes the content hash and the harness asserts cross-repo byte-for-byte agreement.

Changed

  • A2ASubscriber delivery behavior — now retries on 5xx / connection / timeout (#61). Previously single-attempt: A2A failures returned silently after one try. Now A2A applies the unified retry policy (default max_attempts: 3) like WebhookSubscriber. SDK consumers that relied on single-attempt semantics MUST set retry: { max_attempts: 1 } explicitly. Side effects of retries (duplicate downstream processing on retried 5xx) are the receiver's responsibility — implement idempotency at the A2A endpoint.
  • Registry.register — concurrent same-ID registration semantics tightened (#65). Previously a race between two register() calls for the same module_id could resolve via lock-ordering with one succeeding and one failing late. Now an in-flight loading set rejects the second caller immediately with InvalidInputError(code=DUPLICATE_MODULE_ID). Callers that previously assumed race-tolerant insertion MUST serialize per-module-ID registration explicitly. The new behavior is symmetric with sequential same-ID registration, which has always raised DUPLICATE_MODULE_ID.

Notes

  • Issues #61–#65 were discovered during the apcore-a2a upgrade audit and target gaps that bleed into adapter / bridge implementations. They are feature-level changes; docs/spec/protocol-spec.md is unchanged. SDK rollout follow-ups will be filed in apcore-python, apcore-typescript, and apcore-rust.
  • Behavior changes (A2A retry, registry concurrent same-ID) are normative spec changes, not implementation drift. SDKs implementing v0.22.0 MUST adopt the new behavior on the next minor release; SDK CHANGELOGs SHOULD restate these Changed entries.

[0.21.0] - 2026-05-06

Added

  • §2.5 Reserved Words — ephemeral added to framework reserved list (RFC rfc-ephemeral-modules.md accepted). New reserved namespace ephemeral.* for programmatically-generated runtime modules synthesized by LLM agents (à la ToolMaker, ACL 2025). Standard Registry.register() only — register_internal() MUST reject ephemeral.* IDs. Reserved-namespace semantics table added below the YAML block. See docs/spec/rfc-ephemeral-modules.md for the full namespace contract (registration / lifecycle / audit / sandboxing).
  • §4.4 ModuleAnnotations — new discoverable: boolean field (default true). Controls visibility in enumeration surfaces (Registry.list(), Registry.find(), manifest export, MCP tools/list). false hides the module from discovery while keeping it callable by exact ID. ephemeral.* modules SHOULD set discoverable: false.
  • §5.6 Module Interface Protocol — optional preview() method (RFC rfc-preview-method.md accepted). Modules implement preview(inputs, context) -> PreviewResult | null to self-report structured-diff predictions of state changes the call would produce. Sits alongside preflight() (warnings) — they are orthogonal surfaces. Exception semantics mirror preflight() (advisory warning via module_preview check entry; does not fail validation). Pseudocode interface block + optional_methods YAML both updated.
  • §12.8 PreflightResult schema — new predicted_changes: List<Change> field; Change and PreviewResult types defined. Change has required action / target / summary (free-form strings) and optional before / after snapshots; x-* extension fields are permitted (cross-SDK encoding patterns documented in the RFC). PreflightCheckResult.check enum extended with module_preview.
  • §4.6 conventions table — three new x-* AI-routing keys (D-57). Documentation-only registration of three metadata conventions surfaced by the 2025–2026 LLM-agent / tool-use frontier-research alignment audit:
    • x-reasoning-demand (low | medium | high) — hint of the minimum reasoning capability the calling agent needs; consumed by upstream model routers for tier selection. Aligns with RouteLLM (ICLR 2025), xRouter (arXiv 2510.08439), and Cost-Aware Model Orchestration (arXiv 2512.01099).
    • x-required-context-keys — array of context.data key names the module reads, enabling orchestrators to inject required state ahead of the call. Does not include framework-owned Context fields (trace_id, caller_id, etc.).
    • x-supports-dry-run (boolean) — module-level signal that Executor.validate() (§12.2) is meaningful for this module.
  • docs/spec/2026-05-decision-log.md — D-57 (§4.6 reasoning/context/dry-run conventions) — marked resolved. Resolution status block updated to include D-57.

Notes

  • No SDK behavior change for §4.6 D-57 conventions. All three SDKs treat module metadata as a free-form dict / Record<string, unknown> / serde_json::Value; the framework explicitly does not validate metadata content (§4.6 "Metadata Design Principles"). New x-* keys are additive conventions only.
  • Strict-mode export unaffected by D-57. §4.16 mandates that to_strict_schema() strips all x-* extension fields, so the new keys do not surface in strict-mode output.
  • SDK rollout for §2.5 / §4.4 / §5.6 / §12.8 spec promotions is synchronized. All three SDKs ship the full v0.21.0 surface (Stage 2 Module.preview() + Stage 3 ephemeral.* namespace + discoverable annotation + audit single-emit + register_internal() rejection):
    • apcore-python v0.21.0 — pyproject.toml bumped; PR #26 + iter-11 alignment shipped Stage 3; commit 203a9a6 shipped Stage 2.
    • apcore-typescript v0.21.0 — package.json bumped; PR #29 + iter-11 shipped Stage 2 (with TypeBox Type.Unsafe form for Change.x-*); commit 577b09b shipped Stage 3.
    • apcore-rust v0.21.0 — Cargo.toml bumped; PR #25 shipped Stage 2 prerequisite (#[non_exhaustive] hygiene); commits afb6e05 + e6abb7b shipped Stage 2 Module::preview() + Stage 3 ephemeral/discoverable.
  • Conformance fixture annotations_extra_round_trip.json deferred. Per rfc-ephemeral-modules.md "Transitional fixture handling during multi-SDK rollout", the fixture is NOT updated to require discoverable until all 3 SDKs have shipped support. SDKs implementing discoverable MAY make their conformance test runner pilot-tolerant in the interim. Synchronized fixture update will land in a follow-up PR after TypeScript and Rust ship discoverable.

[0.20.0] - 2026-05-05

Added

  • UsageExporter Protocol/interface/trait + NoopUsageExporter + PeriodicUsageExporter in all 3 SDKs (#45 §3, D-55). Push-style usage summary export distinct from pull-style PrometheusExporter. PeriodicUsageExporter polls UsageCollector.summary() every interval_seconds (default 3600 = 1 hour) and calls exporter.export(summary). stop() halts the loop, awaits exporter.shutdown(), and is idempotent. apcore SDKs do not ship transport-bound exporters (HTTP, Kafka, etc.) — those are explicitly out-of-tree. Documented in docs/features/observability.md "## UsageExporter (push-style)". Conformance: conformance/fixtures/usage_exporter.json (3 cases).
  • TypeScript registry _filterIdConflicts 8-stage decomposition matches Rust + Python (D-32, D-56). TypeScript _discoverDefault refactored to expose _filterIdConflicts as a separate private helper between candidate enumeration and registration; _registerInOrder reduced to pure registration. Cross-language conformance no longer special-cases TS — all 3 SDKs follow the canonical 8-stage Algorithm A04 shape.
  • conformance/fixtures/usage_exporter.json (#45 §3, D-55) — 3 cross-language test cases: NoopUsageExporter no-op, PeriodicUsageExporter pushes summary at interval, stop() is idempotent and drains in-flight exports.
  • conformance/fixtures/sensitive_keys_default.json (#43 §5, D-54) — 4 cross-language test cases: canonical 16-entry default list shape, legacy _secret_* prefix redaction under default, common credential-term redaction (case-insensitive substring), operator override replaces (does not merge) the default.
  • docs/spec/2026-05-decision-log.md## D-54 — sensitive_keys canonical default list, ## D-55 — UsageExporter push interface (#45 §3), ## D-56 — TS _discoverDefault 8-stage refactor — all marked resolved. Resolution status block updated to reflect D-30, D-31, D-32, D-54, D-55, D-56 as resolved.
  • docs/features/async-tasks.mdContract: ReaperHandle.stop block (audit N-001) — Normative cross-language declaration that ReaperHandle.stop() MUST be async in all three SDKs (async def in Python, Promise<void> in TypeScript, async fn in Rust) with drain semantics — cancel + await termination — and idempotent: true.
  • conformance/fixtures/trace_context.json (#35) — 8 cross-language test cases for the TraceContext W3C alignment: ordered tracestate roundtrip, 32-entry cap, malformed-entry tolerance, case-insensitive Traceparent/TRACESTATE header lookup, dynamic trace_flags honoring on extract→inject, accepted parent_id override (^[0-9a-f]{16}$), and INVALID_PARENT_ID rejection of malformed overrides.
  • docs/features/event-system.md — Event Naming Convention section (#36) — Normative apcore.<subsystem>.<event> form for framework-emitted events, glob-subscription examples (apcore.registry.* / apcore.health.*) across Python/TypeScript/Rust, and a deprecation table mapping legacy names (module_registered, module_unregistered, apcore.error.threshold_exceeded, apcore.latency.threshold_exceeded) to their canonical replacements with v0.22.0 removal target. Adds a "Configuration-driven subscribers" section listing all five built-in factories (webhook, a2a, file, stdout, filter) with a multi-subscriber YAML example.
  • docs/features/system-modules.md — Contextual Auditing subsection (#45.2) — Normative rule that control modules (update_config, toggle_feature, reload_module) MUST include caller_id and (when present) a redacted identity snapshot in their emitted audit events; caller_id defaults to the literal string "@external" when unauthenticated; x-sensitive Identity fields are replaced with "<redacted>". Cross-language Python/TypeScript/Rust examples of the audit event payload shape.
  • Public SubscriberFactory API in Python SDK (parity with TS+Rust) (#36).
  • conformance/fixtures/event_naming.json (#36 / D-34) — 8 cross-language test cases: canonical apcore.registry.module_registered / apcore.registry.module_unregistered, dual-emit of legacy names with deprecated:true during v0.21.x, glob subscription matching for apcore.registry.* and apcore.health.*, canonical apcore.health.error_threshold_exceeded / apcore.health.latency_threshold_exceeded, and cross-subsystem glob isolation.
  • conformance/fixtures/contextual_audit.json (#45.2 / D-35) — 7 cross-language test cases: caller_id propagation in apcore.config.updated, @external default for null/empty caller_id, redacted identity snapshot inclusion for apcore.module.toggled, x-sensitive field redaction (e.g., bearer_token<redacted>), caller_id + identity in apcore.module.reloaded, and audit event emission even when no AuditStore is configured.
  • docs/spec/2026-05-decision-log.md D-34 (event naming canonicalization) and D-35 (contextual auditing for control plane) — both marked resolved with action: rename + dual-emit during v0.21.x for D-34; payload extension for D-35.
  • Pipeline StepMiddleware extension point in all 3 SDKs (#33). Lifecycle-shaped API (before_step / after_step / on_step_error) mirroring module-level Middleware — onion ordering, first-recovery-wins on errors, async support across Python/TypeScript/Rust. Documented in docs/features/middleware-system.md "Pipeline Step Middleware (Issue #33)" with three contract blocks. Conformance: conformance/fixtures/pipeline_step_middleware.json (6 cases).
  • Python BatchSpanProcessor for non-blocking span export (#43, parity with TS+Rust). Cross-SDK parity contract — identical default tunables (max_queue_size=2048, max_export_batch_size=512, schedule_delay_ms=5000, export_timeout_ms=30000), identical on_end / force_flush / shutdown lifecycle, identical drop-on-full-queue semantics. Documented in docs/features/observability.md "Batch span processing" section.
  • docs/spec/2026-05-decision-log.md — D-36 (Pipeline StepMiddleware), D-37 (Pipeline configuration fail-fast), D-38 (BatchSpanProcessor cross-SDK parity), all resolved.
  • StorageBackend trait/interface in all 3 SDKs with InMemoryStorageBackend default; ErrorHistory, UsageCollector, MetricsCollector accept injected backend (#43).
  • OverridesStore and FileOverridesStore in TypeScript SDK (parity with Python+Rust) (#45.1).
  • Registry.discover_multi_class / Registry.discoverMultiClass method in Python+TS (D-15).
  • docs/features/observability.md## Pluggable storage backends and ## ErrorHistory eviction performance subsections documenting the cross-SDK StorageBackend surface, InMemoryStorageBackend default, out-of-tree policy for Redis/Postgres/S3, and the lazy-deletion semantics of the O(log N) min-heap eviction.
  • docs/features/system-modules.md## Persistent Overrides — pluggable OverridesStore subsection covering OverridesStore parity across all 3 SDKs, with cross-language FileOverridesStore wiring during APCore construction and the missing-path-on-first-run guarantee.
  • docs/features/multi-module-discovery.md — Updated Registry.discover_multi_class Contract block to explicitly document that all 3 SDKs expose the discovery routine as a method on Registry; added a per-SDK method/internal-helper mapping table and cross-language usage examples (D-15).
  • docs/features/async-tasks.md — Notes documenting the Python TaskStore.put → save rename + deprecation alias (D-10), the Python TaskInfo.attempt_number → retry_count rename + deprecation property (D-13), removal of Python's TaskStatus.RETRYING (D-12), and the Rust RetryConfig::default().max_retries: 3 → 0 alignment (D-14).
  • docs/spec/2026-05-decision-log.md## Resolution status — 2026-05-03 addendum marking D-10, D-12, D-13, D-14, D-15, D-58, D-25, D-27, and D-28 resolved; added new entries ## D-39 — StorageBackend cross-SDK abstraction (resolved) and ## D-40 — TS overrides persistence parity (resolved).
  • conformance/fixtures/storage_backend.json — 5 cross-language test cases covering StorageBackend save+get round-trip, list-with-prefix filtering, idempotent delete, namespace isolation, and save-overwrites semantics.
  • conformance/fixtures/overrides_store.json — 5 cross-language test cases covering OverridesStore: save persists across reopen, startup applies overrides after base config, in-memory store for tests, missing path on first run is OK, delete idempotency.
  • Granular reload via path_filter input in ReloadModule across all 3 SDKs (#45.4).
  • Rust Config::reload_from_disk() for refreshing static config without binary restart (#45.5).
  • Error fingerprinting in ErrorHistory across all 3 SDKs — dedup by (error_code, top-frame hash, sanitized message template) instead of exact message (#43 §4).
  • Configurable redaction via obs.redaction.regex_patterns and obs.redaction.sensitive_keys Config keys (#43 §5).

Changed

  • Default obs.redaction.sensitive_keys redaction list expanded to canonical 16-entry superset across all 3 SDKs (#43 §5, D-54). Default now ships as ["_secret_*", "password", "passwd", "secret", "token", "api_key", "apikey", "apiKey", "access_key", "private_key", "authorization", "auth", "credential", "cookie", "session", "bearer"]. The leading _secret_* glob preserves the legacy _secret_-prefix behavior under the canonical default; the redundant apiKey/apikey pair is intentional so cross-SDK fixtures byte-match. Operator overrides via obs.redaction.sensitive_keys in apcore.yaml replace (do not merge with) the default. Documented in docs/features/observability.md "## Canonical default sensitive_keys".
  • Trace context (#35) — docs/features/observability.md W3C Trace Context section adds tracestate propagation (ordered, 32-entry cap, malformed-entry tolerance), case-insensitive header lookup (Traceparent / TRACEPARENT / traceparent all match), dynamic trace_flags honoring (sampling flag from incoming request, NOT hardcoded), and an optional parent_id argument on inject() validated against ^[0-9a-f]{16}$ with INVALID_PARENT_ID rejection. Cross-language Python/TypeScript/Rust examples added.
  • docs/features/async-tasks.md Reaper example (sync B-004 + audit N-001) — Python tab now uses the canonical sync start_reaper(ttl_seconds=, sweep_interval_ms=) signature returning ReaperHandle, and await reaper_handle.stop() for graceful shutdown (was previously sync stop()). TS and Rust tabs unchanged.
  • docs/features/observability.md Prometheus example (sync B-005) — All three tabs show wiring of UsageCollector into PrometheusExporter (Python usage_collector= parameter, TS options-object, Rust with_usage_collector builder), with an admonition explaining the cross-language constructor-vs-builder shape difference.
  • docs/features/multi-module-discovery.md Python import path (sync B-001 / B-002) — Corrected from apcore.discovery import multi_class to from apcore import multi_class, removing an internal contradiction with the rest of the document. The TS tab now references the existing multiClass() decorator.
  • Event names normalized to apcore.<subsystem>.<event> form (#36). Legacy names dual-emitted; removal target v0.22.0.
  • Control-plane modules (update_config, toggle_feature, reload) now include caller_id + identity in audit events (#45.2).
  • Pipeline configuration is now fail-fast: missing step references and unmet requires/provides raise errors instead of logging warnings (#33). ConfigurationError is raised at YAML parse time for unknown step names in pipeline.configure[] and pipeline.step_middleware[]. PipelineDependencyError is raised at strategy construction time for unsatisfied capability declarations. Documented in docs/features/middleware-system.md "Configuration safety" subsection. Conformance: conformance/fixtures/pipeline_failfast_config.json (4 cases).
  • ErrorHistory eviction is now O(log N) via a min-heap (#43).
  • Python TaskStore.putsave (deprecated alias retained); TaskInfo.attempt_numberretry_count (deprecated alias retained); TaskStatus.RETRYING removed (D-10, D-12, D-13).
  • Rust RetryConfig::default().max_retries is now 0 (was 3) (D-14).
  • Rust streaming chunk merge raises STREAM_CHUNK_NOT_OBJECT for non-object chunks (D-58).
  • Rust update_config raises CONFIG_KEY_RESTRICTED for restricted keys (D-25).
  • Rust UsageCollector now computes trend from samples and supports period filter (D-27).
  • Rust ContextLogger output schema aligned with Python+TS (lowercase level, nested extra, module_id) (D-28).
  • Python start_reaper now uses (ttl_seconds, sweep_interval_ms) and returns ReaperHandle matching TS+Rust; old kwargs aliased with DeprecationWarning (D-11).
  • OverridesStore is now a pluggable trait/interface/protocol in all 3 SDKs (#45.1, D-47); FileOverridesStore and InMemoryOverridesStore ship as defaults.
  • Reaper default sweep_interval_ms aligned across 3 SDKs to 300_000 (D-48).
  • TypeScript RetryConfig.computeDelaycomputeDelayMs; Rust RetryConfig::delay_for_attemptcompute_delay_ms (old names deprecated, removal target v0.22.0) (D-08, D-49).
  • Rust inject() now propagates inbound trace_flags from context data (D-50).
  • Rust inject() now returns an error on malformed parent_id override, matching PY+TS (D-51).
  • Rust pipeline ConfigurationError is a distinct error code from PipelineDependencyError (D-52).
  • TypeScript redaction reads canonical obs.redaction.{regex_patterns,sensitive_keys} Config keys; legacy observability.redaction.{field_patterns,value_patterns} honored with deprecation warning (D-53).

Fixed

  • Cross-language naming alignment for the context_namespace middleware module (audit N-003) — Python and Rust shipped ContextWriter / NamespaceCheck; TypeScript shipped ContextKeyWriter / ContextKeyValidation. TypeScript renamed in apcore-typescript v0.20.x to match the Python+Rust majority; the prior TS names are retained as deprecated aliases for one release cycle. No spec text change needed — all three SDKs now agree on the canonical names.
  • Async on_error middleware in Python+TS now detects awaitable/thenable RETURN values rather than inspecting function shape, fixing silent Promise leaks for partial-wrapped handlers (#42).

Documentation

  • D-30: clarified that pre_approval_hook is Python-only. docs/features/multi-module-discovery.md adds a "Python-only pre_approval_hook" subsection explaining that Python imports the file at scan time so the hook protects against arbitrary code execution; TypeScript and Rust parse static AST/source and never import code from disk for discovery, so the hook is not present in those SDKs. Cross-language tabs show the call shape in each SDK.
  • D-31: documented per-language file extension scanning defaults and skip patterns. docs/features/multi-module-discovery.md adds a "File extensions and skip patterns" subsection with a per-SDK table: Python .py (skip __pycache__/, *.pyc, leading _), TypeScript .ts/.js (skip *.d.ts, *.test.*, *.spec.*), Rust .rs only (configurable via with_extensions).

Added — PROTOCOL_SPEC & feature spec hardening (Issues #32–#45)

  • docs/spec/design-durability-boundary.md — Durability Boundary design document. Vendor-neutral architectural document enumerating apcore's stable hooks for retry/replay/workflow layers built on top (Context JSON serialization, Approval Phase B _approval_token retry contract, the TaskStore interface, the six extension points, retry-safety annotations, context.data + §4.6 x- extension mechanism), explicit non-goals (mid-pipeline checkpointing, multi-call workflow orchestration, cost governance, first-class fields for application-level concerns apcore does not consume), five concrete integration patterns (transient retry, crash-durable single-call retry, long-running pause for external decisions, cross-process invocation, logical-call deduplication), and a gap watchlist of deliberately-deferred items. Establishes that apcore is the module standard, durable execution is a runtime concern; both can coexist without apcore absorbing workflow surface.

  • docs/features/system-modules.md — System Modules Hardening section (Issue #45) — Five production-hardening extensions: (1) optional overrides_path / KV-store persistence for system.control.update_config and system.control.toggle_feature changes, loaded after base config on startup via pluggable OverridesStore interface; (2) contextual audit trail — all state-modifying control modules extract context.identity and append structured AuditEntry records (timestamp, action, actor_id, actor_type, trace_id, before/after change) via a pluggable AuditStore interface; (3) Prometheus integration for UsageCollector — five new gauges/counters (apcore_usage_calls_total, apcore_usage_error_rate, apcore_usage_p50/p95/p99_latency_ms) exported via the existing /metrics endpoint with configurable export_timeout_ms; (4) granular path_filter glob field on system.control.reload_module for bulk reload in dependency topological order, mutually exclusive with module_id (MODULE_RELOAD_CONFLICT on conflict); (5) startup failure handling — fail_on_error: bool = False parameter for Python/TypeScript register_sys_modules(), failOnError: boolean = false for TypeScript, and Result<(), SysModuleError> return type for Rust (never panics or returns Option). Updates register_sys_modules contract block with new fail_on_error parameter and SYS_MODULE_REGISTRATION_FAILED error code. Closes Issue #45.

  • conformance/fixtures/system_modules_hardening.json — 10 cross-language test cases covering: overrides persisted on update_config, overrides loaded after base config on startup, audit entry actor_id extracted from context.identity, audit entry before/after change for toggle_feature, Prometheus /metrics includes apcore_usage_calls_total, path_filter reloads matching modules in topological order, module_id + path_filter conflict raises MODULE_RELOAD_CONFLICT, fail_on_error=True raises SysModuleRegistrationError, fail_on_error=False logs and continues, and Rust register_sys_modules returns Result not Option.

  • docs/features/observability.md — Observability Hardening section (Issue #43) — Six production-hardening extensions: (1) pluggable ObservabilityStore interface with InMemoryObservabilityStore default and optional RedisObservabilityStore/SqlObservabilityStore backends injected at construction time; (2) BatchSpanProcessor for non-blocking OTEL export with configurable max_queue_size (2048), schedule_delay_ms (5000), max_export_batch_size (512), and export_timeout_ms (30000) — drops spans on full queue with spans_dropped counter; (3) O(log N) ErrorHistory eviction using a min-heap keyed on last_seen_at plus an O(1) module-id index, replacing the O(M) ring-buffer scan; (4) SHA-256 content-addressable error fingerprinting with UUID/ID/timestamp normalization for accurate deduplication; (5) RedactionConfig with glob field_patterns and regex value_patterns applied at log time (union with existing x-sensitive rules); (6) K8s/Prometheus integration hooks (/metrics, /healthz, /readyz) with required apcore_module_calls_total, apcore_module_errors_total, apcore_module_duration_seconds metrics and ServiceMonitor annotation guidance. Adds PrometheusExporter.export contract block. Closes Issue #43.

  • conformance/fixtures/observability_hardening.json — 10 cross-language test cases covering: default InMemoryObservabilityStore construction, BatchSpanProcessor buffering without immediate export, BatchSpanProcessor drop-on-full-queue with spans_dropped increment, min-heap eviction of oldest last_seen_at entry, fingerprint-based deduplication (count increment, no duplicate entry), UUID normalization producing identical fingerprints, different error codes producing distinct fingerprints, field-pattern redaction (*password*), value-pattern redaction (^Bearer .*), and Prometheus export containing all three required metric names.

  • docs/features/async-tasks.md — AsyncTaskManager Evolution section (Issue #34) — Three new capability extensions: (1) pluggable TaskStore interface with InMemoryTaskStore default and optional RedisTaskStore/SqlTaskStore backends injected at construction time; (2) per-task retry configuration (max_retries, retry_delay_ms, backoff_multiplier, max_retry_delay_ms) with exponential backoff scheduling and FAILED terminal state after exhaustion; (3) opt-in Reaper background task for automatic TTL-based deletion of terminal-state tasks, guarded by reaper_enabled: true. Includes TaskStore.save and AsyncTaskManager.start_reaper contract blocks. Closes Issue #34.

  • conformance/fixtures/async_task_evolution.json — 10 cross-language test cases covering: default InMemoryTaskStore construction, custom store injection, save-and-get round-trip, list-by-status filtering, retry scheduling on first failure, backoff multiplier delay computation, max-retries-exhausted → FAILED transition, Reaper disabled by default, Reaper deletes expired terminal tasks, and Reaper skips PENDING/RUNNING tasks regardless of age.

  • docs/features/schema-system.md — Schema System Hardening section (Issue #44) — Five hardening areas: (1) union type standardization — all SDKs MUST evaluate ALL branches of anyOf/oneOf (not short-circuit on first); (2) recursive schema support via lazy $ref resolution (model_rebuild in Python, Type.Recursive in TypeScript, Box<T> in Rust); (3) Rust validator parity — normative requirement to support allOf/anyOf/oneOf/not and numerical/string constraints, with jsonschema-rs as recommended path; (4) semantic format mapping — SHOULD map date-time, date, time, email, uri, uuid, ipv4, ipv6 to language-native types; (5) content-addressable schema cache keyed by SHA-256 of canonical JSON — deduplicates identical schemas loaded from different paths. Adds Schema.validate_union, Schema.validate_recursive, and Schema.content_hash contract blocks. Closes Issue #44.

  • conformance/fixtures/schema_hardening_union.json — 8 test cases for anyOf all-branches evaluation, oneOf exactly-one enforcement, oneOf ambiguous-match error, allOf all-satisfied and one-fails cases.

  • conformance/fixtures/schema_hardening_recursive.json — 6 test cases for TreeNode $ref: "#" recursive schema at depths 1, 3, and 5; invalid child type; empty and absent children arrays.

  • conformance/fixtures/schema_hardening_constraints.json — 12 test cases for minimum/maximum, exclusiveMinimum boundary, minLength/maxLength, pattern match/no-match, and not keyword.

  • conformance/fixtures/schema_hardening_formats.json — 9 test cases for semantic format mapping across all 8 canonical formats; invalid formats produce warn_logged=true, not hard errors.

  • conformance/fixtures/schema_hardening_cache.json — 5 test cases for content-hash deduplication: same-content two-paths, different-content, key-order canonical normalization, pre/post-$ref resolution, empty schema.

  • NestContextFactory in nestjs-apcore (Issue #35) — New NestContextFactory service (src/context/nest-context.factory.ts) reads W3C traceparent header (header key normalized to lowercase), x-correlation-id/x-request-id correlation headers (stored in context.data["x-correlation-id"]), and x-user-id/Authorization: Bearer for identity. 13 unit tests covering all fixture semantics. Exported from package index. This completes the framework-factories task for Issue #35 — Django, Flask, and NestJS all now have default ContextFactory implementations. Also fixes PROTOCOL_SPEC.md §8.5 error response example (trace_id updated from dashed UUID to 32-char lowercase hex, consistent with §5.7 and §10.5 changes shipped in v0.19.0). Closes Issue #35.

  • PROTOCOL_SPEC §5.16 — Pipeline Control Flow Requirements — Normative rules for fail-fast error handling, O(1) step lookups, replace semantic for step configuration, run_until termination predicate, and step-level middleware ordering. Closes Issue #33.

  • docs/features/core-executor.md — Pipeline Hardening section — Implementation guidance for all 5 hardening areas (fail-fast, replace semantic, step-level middleware, run_until, O(1) lookups) with cross-language Python/TypeScript/Rust examples and a Pipeline.configure_step contract block.

  • conformance/fixtures/pipeline_hardening.json — 5 test cases for fail-fast, continue-on-ignored-error, replace-semantic, run_until early termination, and O(1) lookup verification.

  • docs/features/event-system.md — Event Management Hardening section — Cross-language SubscriberFactory parity (TypeScript + Rust examples), three new built-in subscriber types (file, stdout, filter), circuit-breaker resilience spec with OPEN/CLOSED/HALF_OPEN state machine, apcore.subscriber.circuit_opened/circuit_closed events, SubscriberCircuitBreaker.on_failure contract. Closes Issue #36.

  • conformance/fixtures/event_management_hardening.json — 10 test cases for SubscriberFactory registration, built-in subscriber types, filter pass/discard, circuit-breaker state transitions.

  • PROTOCOL_SPEC §2.1.1 — Multi-Class Discovery — Opt-in mode allowing multiple Module classes per file. ID is derived as base_id.snake_case(ClassName). Single-class files produce unchanged IDs (backward-compatible). Introduces MODULE_ID_CONFLICT error for classes that produce the same snake_case segment. Closes Issue #32.

  • docs/features/multi-module-discovery.md — Full feature spec with discovery algorithm, conflict detection, backward compatibility guarantees, and cross-language usage examples.

  • conformance/fixtures/multi_module_discovery.json — 8 test cases for ID derivation, snake_case conversion, conflict detection, and backward compatibility.

  • docs/features/middleware-system.md — Middleware Architecture Hardening section — Context namespacing rules (_apcore.* vs ext.*), CircuitBreakerMiddleware spec (per-module error tracking, rolling window, OPEN/HALF_OPEN/CLOSED state machine, CircuitBreakerOpenError, apcore.circuit.opened/closed events), TracingMiddleware spec (OTLP-compatible span lifecycle, graceful no-op when OpenTelemetry is absent), YAML-driven declarative middleware configuration (tracing, circuit_breaker, logging, custom types), and async handler detection fix (inspect.iscoroutinefunction in Python, handler.constructor.name in TypeScript). Includes Middleware.detect_async contract block. Closes Issue #42.

  • conformance/fixtures/middleware_hardening.json — 10 test cases covering context namespace validation (valid _apcore.*, valid ext.*, violation), circuit breaker state transitions (opens at threshold, short-circuits in OPEN, probes in HALF_OPEN, closes on success), tracing span creation and no-op without OTel, and async coroutine detection correctness.

Changed — PROTOCOL_SPEC clarifications

  • PROTOCOL_SPEC §7 — Approval Phase B Resume semantics clarified. Added a normative paragraph after the Step 5 Algorithm formalizing existing reference behavior: when a caller retries an APPROVAL_PENDING call by injecting _approval_token into arguments, the executor MUST re-enter the pipeline from Step 1 with no preserved intermediate PipelineContext state. Pre-approval middleware side effects re-execute on resume; middleware needing at-most-once semantics across an approval gate SHOULD inspect _approval_token itself. No behavioral change — documents existing implementation. Cross-references docs/spec/design-durability-boundary.md §2.2.

Fixed — Documentation polish

  • docs/features/async-tasks.md — TypeScript await missing on async methods. The TypeScript tab was calling manager.submit(), manager.cancel(), and manager.shutdown() without await, while the Python and Rust equivalents correctly used await/.await. All three calls are now correctly awaited.
  • docs/features/event-system.md — "Via Direct EventEmitter" section missing TypeScript and Rust tabs. The section contained only bare Python code with no tab wrapper. Added === "Python" / === "TypeScript" / === "Rust" tabs consistent with every other behavioral section in the file.
  • docs/features/observability.md — Lowercase must in normative Requirements section. InMemoryExporter must be bounded corrected to InMemoryExporter MUST be bounded per RFC 2119.
  • PROTOCOL_SPEC.md §5.16 — RFC 2119 keywords unbolded. All MUST, MUST NOT, and SHOULD occurrences in the §5.16 Pipeline Control Flow Requirements section (intro sentence + 5 numbered items) were plain text. Updated to **MUST** / **MUST NOT** / **SHOULD** consistent with the rest of the specification.
  • conformance/README.md — Non-Standard Test Patterns section incomplete. schema_hardening_recursive.json (shared root-level schema) and schema_hardening_formats.json (root-level format_mappings reference metadata) were not documented alongside the existing context_serialization.json and annotations_extra_round_trip.json entries. Added entries explaining both patterns for SDK test runner authors.

[0.19.0] - 2026-04-19

Changed

  • PROTOCOL_SPEC §5.7 — trace_id format pattern tightened from format: uuid (ambiguous: dashed vs hex) to pattern: "^[0-9a-f]{32}$" (32-char lowercase hex, W3C Trace Context compatible). Resolves internal spec contradiction between §5.7 (format: uuid), §10.5 example (dashed UUID), and §10.5 traceparent_header (hex without dashes).
  • PROTOCOL_SPEC §10.5 — Trace ID Format section rewritten to align with W3C Trace Context Level 2. Adds explicit external_trace_parent_handling rules: strict 32-hex validation only; all-zero and all-f rejected per W3C; no auto-normalization (dashed UUID stripping, case folding) at Context.create — pushed to TraceParent parser or user's ContextFactory. Implementations MUST regenerate + log WARN on invalid input; MUST NOT raise.

Added

  • PROTOCOL_SPEC §5.15.2 — DEPENDENCY_VERSION_MISMATCH error code (new, non-retryable). Raised when a module in dependencies.requires exists but its registered version does not satisfy the declared version constraint. For dependencies.optional the same situation logs WARN and skips the dependency edge.
  • conformance/fixtures/dependency_version_constraints.json — 15 cross-SDK test cases covering exact match, >=/<=, range (>=1.0.0,<2.0.0), caret (^1.2.3, including ^0.2.3 major-zero semantics), tilde (~1.2.3), partial-version shortcuts ("1" matches 1.x.x), no-constraint accepts any, and optional-dependency skip-on-mismatch.
  • PROTOCOL_SPEC §5.7 — Note on external correlation IDs. Documents that existing projects' request/correlation identifiers (X-Request-ID, ULID, AWS X-Ray, etc.) SHOULD be preserved in context.data["x-correlation-id"] alongside trace_id, not used to overwrite it. Establishes the dual-ID model at the spec level.
  • PROTOCOL_SPEC §10.5 — Forward-compatibility Note. Non-normative acknowledgment that trace_id format is a versioned contract, not a permanent guarantee. Future protocol versions MAY introduce alternative formats or structured trace IDs for multi-agent or non-linear trace topologies.
  • docs/guides/integrating-existing-projects.md — New user guide covering Django, Express, and Actix integration patterns for projects already carrying their own request-ID / correlation-ID system.
  • conformance/fixtures/context_trace_parent.json — Cross-language fixture with 10 test cases covering valid 32-hex, dashed UUID rejection, uppercase rejection, W3C-invalid all-zero/all-f rejection, length errors, non-hex characters, empty string, and the no-trace_parent baseline.
  • docs/spec/DECLARATIVE_CONFIG_SPEC.md v1.0 — New unified specification for declarative YAML configuration across all three SDKs. Covers bindings YAML (§3), pipeline config (§4), entry-point meta (§5), auto_schema semantics (§6), canonical error model with exact message templates (§7), configurable policy limits (§9). Establishes core principles: YAML syntax 100% consistent across SDKs; never silently drop fields; auto-processing as default; JSON Schema for structure, apcore.yaml for policy.
  • schemas/binding.schema.json updates — Relaxed target regex to support TypeScript ESM specifiers (./relative, @scope/pkg) and Rust handler-map keys. auto_schema field now accepts boolean or "true" / "permissive" / "strict" enum. Schema mode mutex rules rewritten from oneOf to allOf + if/then (supports implicit auto default when no mode specified). DisplayOverlay changed from additionalProperties: false to propertyNames pattern + additionalProperties: SurfaceOverride for future surface extensibility. Annotations expanded from 5 to 12 fields (added streaming, cacheable, cache_ttl, cache_key_fields, paginated, pagination_style, extra) to align with module-meta.schema.json. version field pattern moved to configurable policy (version_require_semver). Added spec_version top-level field.
  • schemas/apcore-config.schema.jsonpipeline and validation sections — New PipelineConfig definition with remove, configure, steps structure; PipelineStep with type/handler mutual exclusion, after/before positioning, and metadata fields (match_modules, ignore_errors, pure, timeout_ms). New ValidationConfig with configurable policy limits for bindings (description_max_length, documentation_max_length, tags_pattern, version_require_semver) and pipeline (step_name_max_length, timeout_ms_max).
  • conformance/fixtures/binding_yaml_canonical.yaml — Cross-SDK binding YAML conformance fixture with 3 entries testing explicit auto_schema (permissive), explicit input/output schemas with display overlay, and auto_schema strict mode. All three SDKs must parse this fixture identically.
  • conformance/fixtures/binding_errors.json — 6 canonical error message test cases for cross-SDK byte-for-byte message parity (BindingFileInvalidError, BindingSchemaModeConflictError, BindingSchemaInferenceFailedError, PipelineHandlerNotSupportedError, BindingInvalidTargetError, BindingModuleNotFoundError).

Fixed

  • PROTOCOL_SPEC §8 Error Code Registry — Added missing DEPENDENCY_VERSION_MISMATCH entry alongside DEPENDENCY_NOT_FOUND. Previously the behavior was implied by §5.3 version constraint syntax (^, ~, ranges) without a corresponding error code in §8, leaving SDKs without a normative failure mode.
  • SDK compliance with §5.15.2 DEPENDENCY_NOT_FOUND — All three SDKs (apcore-python, apcore-typescript, apcore-rust) now raise the spec-mandated DEPENDENCY_NOT_FOUND error code for missing required dependencies. Previously all three raised MODULE_LOAD_ERROR, silently diverging from the spec.
  • SDK CircularDependencyError.details.cycle_path parity — Rust SDK now carries cycle_path as structured details, matching Python (details["cycle_path"]) and TypeScript (details.cyclePath). Previously Rust only placed the path in the message string, forcing downstream consumers to parse it.
  • PROTOCOL_SPEC §8.5 — trace_id example updated to 32-char lowercase hex. The error response example still used the old dashed UUID form (550e8400-e29b-41d4-a716-446655440000), contradicting the §5.7 and §10.5 tightening shipped in this same release. Updated to 4bf92f3577b34da6a3ce929d0e0e4736.

Deprecated

  • The pre-existing MUST NOT allow externally provided unvalidated trace_id rule in §10.5 is superseded by the explicit validation/normalization pipeline — "unvalidated" is now impossible because every input is either accepted verbatim or replaced with a fresh trace_id.

[0.18.0] - 2026-04-15

Breaking changes in this release. See MIGRATION-v0.18.md for the consolidated migration guide covering all four repositories.

Added

  • APCore constructor simplified — removed config_path parameter; use Config.load() instead (e.g. APCore(config=Config.load("apcore.yaml"))). This applies to all SDKs: Python, TypeScript, and Rust. The explicit two-step pattern keeps the constructor focused and avoids mutual-exclusivity validation.
  • System module JSON Schemas — 6 new output schemas in schemas/: sys-control-reload-module, sys-control-toggle-feature, sys-control-update-config, sys-health-module, sys-health-summary, sys-manifest-full. Each declares $schema, $id, additionalProperties: false, and descriptions on every property.
  • Conformance README coverage gap table — Documents 6 algorithms (A01, A07, A12, A21, A22, A23) that lack conformance fixtures.
  • Conformance README non-standard patterns section — Documents sub_cases and forbidden_root_keys test patterns for SDK implementers.
  • PROTOCOL_SPEC §2.7 — canonical_id maximum length raised from 128 to 192 characters. Motivated by deep-namespace languages (Java/.NET/Spring FQN-derived IDs) where snake_case-converted fully-qualified names can exceed 128 in edge cases. 192 is filesystem-safe (192 + ".binding.yaml" = 205 bytes < 255-byte filename limit on ext4/xfs/NTFS/APFS/btrfs) and remains within VARCHAR(255) for typical persistence layers. MCP alias 64-char hard limit (OpenAI function name spec) is unchanged and still requires alias mapping for any module_id > 64. Schemas updated: binding.schema.json, module-schema.schema.json, module-meta.schema.json declare module_id.maxLength: 192; acl-config.schema.json callers/targets pattern strings raised to 192 to remain symmetric. Algorithm A01 (directory_to_canonical_id) Step 6 length threshold updated to 192. Conformance test T01-006 boundary updated to >192 chars. Forward-compatible relaxation: producers targeting mixed-version ecosystems should keep IDs ≤ 128 until all consumers are upgraded.
  • PROTOCOL_SPEC §5.6 yaml block now declares required_attributes: (input_schema, output_schema, description) explicitly. The pseudocode block already marked these as "Required definitions" but the yaml block beneath only listed required_methods and optional_attributes, so a reader of the yaml alone could not see the attribute requirement. Closes a sync audit contradiction.
  • PROTOCOL_SPEC §4.4.1 — Annotations Extension Field (extra) Wire Format — New normative section defining the canonical on-the-wire shape of ModuleAnnotations.extra. Producers MUST serialize as a nested {"extra": {...}} object and MUST NOT flatten extension keys to the annotations root. Consumers MUST accept the nested form; legacy top-level overflow keys MAY be tolerated for one MINOR cycle. When both forms appear in the same input, the nested value wins.
  • extra field in Annotations schemaschemas/module-meta.schema.json now declares extra as an object with additionalProperties: true. The outer Annotations object retains additionalProperties: false, so unknown root-level keys are no longer silently accepted at the schema layer.
  • conformance/fixtures/annotations_extra_round_trip.json — 8 cross-language test cases locking the wire format: canonical nested round-trip, empty extra, namespaced keys, Unicode and nested object values, legacy flattened deserialization tolerance, nested-wins precedence, forbidden-root-keys negative case, and dotted-keys-are-not-paths.
  • MIGRATION-v0.18.md — Consolidated migration guide covering all four breaking changes shipped in this release (annotations wire format, apcore-rust Config restructure, apcore-python event alias removal, misc cleanup).
  • 8 new feature specification docs. The following features were implemented in both apcore-python and apcore-typescript SDKs but had no corresponding feature spec in the protocol repo:
    • docs/features/error-system.md — Structured error hierarchy (30+ error types), error codes, AI guidance fields (retryable, ai_guidance, user_fixable, suggestion), ErrorCodeRegistry for custom module error codes.
    • docs/features/extension-system.mdExtensionManager with 6 built-in extension points (discoverer, middleware, acl, span_exporter, module_validator, approval_handler), plugin wiring via apply().
    • docs/features/call-chain-guard.md — Algorithm A20: call depth limiting, circular call detection, frequency throttling with configurable thresholds.
    • docs/features/cancellation.mdCancelToken cooperative cancellation, executor timeout integration with 5-second grace period.
    • docs/features/async-tasks.mdAsyncTaskManager for background module execution with concurrency semaphore, task lifecycle tracking, and cleanup.
    • docs/features/streaming.md — Three-phase streaming pipeline (setup → chunk emission → post-validation), deep merge with depth cap.
    • docs/features/identity-system.mdIdentity data structure with well-known types (user, service, ai, system, anonymous), ContextFactory protocol for web framework integration.
    • docs/features/apcore-client.mdAPCore unified client feature spec covering initialization modes, auto-registration behavior, and method summary.
  • mkdocs.yml navigation updated. Feature Specifications section expanded from 11 to 19 entries (alphabetically sorted).
  • README.md Documentation Index updated. Added all 8 new feature docs to the Feature Specifications table.

Fixed

  • PROTOCOL_SPEC §10.8 — Span attribute "caller" corrected to "caller_id". The span naming convention section listed "caller" as a SHOULD attribute, inconsistent with the caller_id field name used everywhere else in the specification (§2.1, §6, §7, Context object). All SDKs already used caller_id in their span implementations.
  • PROTOCOL_SPEC §9.3 — RFC 2119 keyword compliance. Five constraint validation statements in the A12_validate_config pseudocode used lowercase must instead of uppercase MUST. Corrected to match RFC 2119 normative intent.
  • mkdocs.yml navigation — 4 orphaned docs now reachable. Added api/client-api.md, features/config-bus.md, features/event-system.md, and features/system-modules.md to the site navigation. These pages existed and were referenced in README.md but were not in the MkDocs nav tree.
  • JSON Schema property descriptions. Added missing description fields to 4 properties in module-schema.schema.json (ErrorSchemaObject.type, .properties, .required, and the description property definition) and 3 surface-override objects in binding.schema.json (cli, mcp, a2a). All properties now comply with the "every property must have a description" rule.
  • docs/features/streaming.md — Step count corrected from "Steps 1–6" to "Steps 1–7". The text listed 7 pipeline phases (Context Creation through Input Validation) but labelled them "Steps 1–6". PROTOCOL_SPEC §6574 explicitly states "Steps 1–7 identical to call()" for stream().
  • docs/api/executor-api.mdvalidate() step count corrected. Previously said "Steps 1-7"; now says "Steps 1-6, plus optional module-level preflight" to match PROTOCOL_SPEC §12.8.
  • docs/spec/conformance.md T11-001 — Deprecated execute_async() replaced with call_async(). The execute_async() method was removed in v0.10; conformance table still referenced the old name.
  • docs/guides/creating-modules.md — Rust examples added. Two tabbed code sections (module definition and module usage) only had Python and TypeScript examples. Added Rust tabs with complete, importable examples using apcore::{Module, Context, Registry, Executor}.
  • Cross-language extra serialization divergence — Audit revealed that apcore-rust ≤ 0.17.1 used #[serde(flatten)] and emitted extension keys at the annotations root, while apcore-python and apcore-typescript emitted nested extra objects. A binding round-tripped through Rust would silently lose the extra payload (the nested object collapsed into extra["extra"]). All three SDKs are now aligned on the nested form per §4.4.1.
  • Python/TypeScript precedence inversion — Both SDKs previously merged top-level overflow over explicit nested extra ({**explicit, **overflow}), making nested values losable. Per §4.4.1 rule 7, nested now wins. Behavior change is observable only when the same key appears in both forms in the same input — a pathological case that no conformant producer emits.
  • apcore-rust Config no longer silently ignored spec-conformant YAML. The struct previously declared executor and observability fields at the root of Config instead of nested under executor and observability namespaces, contradicting PROTOCOL_SPEC §9.1 and the Python/TypeScript SDKs. Loading a YAML file that used the canonical nested form would cause typed fields to remain at default values while the user data ended up in an unused settings HashMap entry. v0.18.0 restructures apcore-rust Config to a nested form, drops the legacy short field names, and rejects v0.17.x-style YAML with a hard error pointing at MIGRATION-v0.18.md. See apcore-rust/CHANGELOG.md for the full type-by-type rename table.
  • docs/spec/design-context-annotations-acl.md no longer contradicts shipped spec. The historical design document still claimed "ModuleAnnotations is frozen with 11 fields" and recommended #[serde(flatten)] for the Rust extra field. A superseded banner now points readers at PROTOCOL_SPEC §4.4.1 for current normative behavior; the original text is preserved for historical context.
  • mkdocs.yml Home nav now points at README.md (which exists) instead of index.md (which never did), eliminating a noisy mkdocs build warning.
  • PROTOCOL_SPEC §2.1 — Algorithm A01 step 6 and the directory_to_id YAML format block still said max_length: 128 after the §2.7 bump to 192. Both updated to 192. Internal contradiction with the §2.7 EBNF constraint and the version history changelog entry resolved.
  • PROTOCOL_SPEC §3868 cross-reference cleanupSelf-Evolution: ... runtime reconfiguration (see §6.6, §10) repointed to (see §9.11 Hot-Reload, §10 Observability). §6.6 is "System Module Permissions" — unrelated to runtime reconfiguration. Stale reference from a §6.x renumbering.
  • docs/spec/design-execution-pipeline.md:1248 — Phase 4 implementation table replaced obsolete VALIDATE_ONLY preset with the canonical MINIMAL preset, matching the canonical 5-preset enumeration at line 680 (standard, internal, testing, performance, minimal). VALIDATE_ONLY was replaced by dry_run per §4.
  • docs/spec/design-execution-pipeline.md:1192validate() return type table updated to show all three SDKs unified on PreflightResult (Rust column previously showed ValidationResult with a "long-term plan to unify" note; the unification was completed in this release).
  • docs/features/approval-system.md — Wrong pipeline step numbers. Input Validation was cited as "Step 6" in three places; it is actually Step 7. Middleware Before Chain is Step 6. Corrected all references.
  • docs/features/event-system.md — Missing canonical event name prefix. error_threshold_exceeded and latency_threshold_exceeded were listed without the apcore. prefix. Per PROTOCOL_SPEC §9.16, canonical names are apcore.error.threshold_exceeded and apcore.latency.threshold_exceeded; the unprefixed forms are legacy aliases. Event Types table corrected.
  • docs/features/config-bus.md — Wrong PROTOCOL_SPEC section references. "Typed Bind (§9.8)" corrected to §9.9.3 (§9.8 is Environment Variable Override). "Hot Reload (§9.9)" corrected to §9.11 (§9.9 is Namespace-Aware Access API).
  • docs/features/streaming.md — TypeScript example fixed. Replaced incorrect client.module() with async function* (FunctionModule doesn't support streaming) with correct Module interface implementation using a stream() method. Fixed deep merge table: arrays are replaced, not concatenated.
  • docs/features/error-system.md — Reserved prefix list corrected. Added missing prefixes (DEPENDENCY_, CALL_, MIDDLEWARE_, VERSION_, ERROR_CODE_), removed incorrect ones (RELOAD_, EXECUTION_, ERROR_FORMATTER_). Added missing error classes (MiddlewareChainError, ErrorCodeCollisionError, DependencyNotFoundError).
  • docs/features/call-chain-guard.md — Algorithm description corrected. Circular detection now accurately describes the prior-chain extraction + last-occurrence check, matching both SDK implementations.
  • docs/features/cancellation.md — TypeScript Context example fixed. CancelToken is passed via new Context() constructor, not Context.create() which doesn't accept it.
  • docs/features/async-tasks.md — Type corrections. TaskInfo.result type corrected from dict[str, Any] to Any. get_result() return type corrected from dict to Any.
  • docs/concepts.md — Removed Module inheritance. All class XModule(Module): examples changed to class XModule: to match the spec requirement "Modules MUST NOT inherit from an ABC."
  • docs/architecture.md — Fixed 3 broken links. ../api/*.md paths corrected to ./api/*.md (architecture.md is in docs/, not a subdirectory).
  • docs/guides/middleware.md — Removed non-existent AsyncMiddleware class. Replaced with standard Middleware subclass with async method overrides, which both SDKs support.
  • docs/api/executor-api.md — Sync/async clarity. call() docstring updated from "Synchronously call module" to "Call module (synchronous in Python, async in TypeScript/Rust)."
  • docs/api/client-api.md — Sync/async clarity. Section "4.1 Synchronous Call" renamed to "4.1 Basic Call" with note explaining Python sync vs TypeScript/Rust async.
  • docs/spec/type-mapping.md — Go/Java SDK scope clarified. Added note that Go and Java type mappings are for future implementers; only Python, TypeScript, and Rust have official SDKs.
  • docs/guides/ — Cross-language notes added. acl-configuration.md, adapter-development.md, and testing-modules.md now have a note at the top explaining how Python examples apply to TypeScript and Rust.
  • PROTOCOL_SPEC.md RFC 2119 compliance — 5 instances of lowercase bold **must**/**should** in normative contexts (§1.6, §4.2, §5.6, §11.6) corrected to uppercase **MUST**/**SHOULD**.
  • PROTOCOL_SPEC.md §11.6 extension point semantics — Clarified ambiguous mixed requirement: "Implementations SHOULD support the following extension points. Each supported extension point MUST define a clear interface contract."
  • docs/spec/algorithms.md RFC 2119 compliance — 5 lowercase must in A12 config validation constraints (lines 895–899) corrected to uppercase MUST.
  • schemas/sys-control-update-config.schema.json — Added missing type declarations to old_value and new_value properties.
  • schemas/defaults.schema.json — Added explicit additionalProperties: false to root and all 9 nested objects.
  • schemas/sys-health-summary.schema.json — Added explicit additionalProperties: false to root and all 3 nested objects.
  • CHANGELOG.md v0.16.0 date — Corrected from 2026-04-05 to 2026-04-03 (matching git history).
  • docs/features/config-bus.md — Converted all code examples to cross-language tabbed format (=== "Python" / === "TypeScript" / === "Rust"). Added missing Rust tab in Introspection section.
  • Orphaned docs removed from docs/features/ — Moved 4 code-forge planning docs (acl-conditions-redesign.md, annotations-redesign.md, context-redesign.md, overview.md) out of the published docs tree; these already exist in planning/.
  • docs/spec/design-execution-pipeline.md — Merged design-pipeline-v2.md content, removed stale legacy alias column.
  • Event naming standardization — Canonical apcore.* prefix enforced across event system docs and identity rule schemas.
  • Multi-language documentation — Added cross-language examples and tabbed sections to feature docs, API references, and getting-started guide.

Removed (BREAKING — apcore-python)

  • Legacy event aliases. apcore-python no longer emits module_health_changed or config_changed alongside the canonical apcore.module.toggled, apcore.health.recovered, apcore.config.updated, and apcore.module.reloaded events. The dual-emission deadline was originally v0.16.0 but was missed; this release completes the cleanup. See migration guide §3.

Removed (BREAKING — apcore-rust)

  • Legacy flat Config field names. Config.max_call_depth, Config.default_timeout_ms, Config.global_timeout_ms, Config.max_module_repeat, Config.enable_tracing, Config.enable_metrics are gone. Use the nested namespaces (Config.executor.*, Config.observability.*). The string-key API also drops the legacy bare-name aliases — config.get("max_call_depth") now returns None; use config.get("executor.max_call_depth"). See migration guide §2.

[0.17.1] - 2026-04-06

Added

  • minimal strategy preset — 4-step pipeline (context_creationmodule_lookupexecutereturn_result) for pre-validated internal hot paths. Documented in all three SDKs and spec.
  • Core vs Optional steps overview — New §4.0 in design-execution-pipeline.md with ASCII diagram showing which 4 steps are mandatory and which 7 are removable.
  • Middleware vs Custom Step selection guide — Decision matrix in design-execution-pipeline.md §4.2 and practical guide with cross-language examples in docs/guides/middleware.md §11.
  • requires / provides step dependency metadata — Optional advisory fields on BaseStep (Python), Step interface (TypeScript), and Step trait (Rust). ExecutionStrategy warns at construction and insertion time if a step's requires are not satisfied by preceding steps' provides.
  • Execute step replacement warning!!! warning admonition in design-execution-pipeline.md explaining risks of replacing the execute step.
  • Strategy summary table — All preset strategies documented with step counts, removed steps, and use cases.

Fixed

  • PROTOCOL_SPEC.md pipeline order — Steps 6/7 swapped to match all SDK implementations (middleware_beforeinput_validation). Step 2 renamed from "Safety Checks" to "Call Chain Guard".
  • design-execution-pipeline.md alignment — Step inventory table, code examples, JSON pipeline example, and strategy references updated for correct stage order, naming, and validate_only removal.
  • validate_only strategy removed from spec — Never implemented in any SDK; validate() method with dry_run=True provides the same behavior more cleanly.

[0.17.0] - 2026-04-04

Added

Pipeline v2 — Declarative Step Metadata

  • 4 new BaseStep fieldsmatch_modules (glob patterns for selective step execution), ignore_errors (fault-tolerant continuation on step failure), pure (no side effects, safe for dry-run/validate mode), timeout_ms (per-step timeout in milliseconds). Implemented across all three SDKs (Python, TypeScript, Rust).
  • PipelineContext.dry_run — When true, PipelineEngine skips steps with pure=false, enabling validate() to run user-defined pure steps automatically.
  • PipelineContext.version_hint — Passed through to module_lookup for version negotiation.
  • PipelineContext.executed_middlewares — Tracks which middleware ran for on_error recovery chain.
  • StepTrace.skip_reason — Records why a step was skipped: "no_match", "dry_run", or "error_ignored".
  • YAML pipeline configuration (Python) — pipeline section in apcore.yaml with remove, configure, and steps directives. Step resolution via type (registry) and handler (import path).

Changed

Pipeline Step Rename

  • safety_checkcall_chain_guard — Renamed across all SDKs to accurately describe the step's purpose (call chain depth/cycle/repeat checks, not transport-level rate limiting).
  • TypeScript builtin. prefix removed — All built-in step names in the TypeScript SDK dropped the builtin. prefix for cross-SDK consistency (e.g., builtin.context_creationcontext_creation).

Pipeline Step Order

  • Steps 6 and 7 swappedmiddleware_before now executes before input_validation (was the reverse). Middleware transforms are now validated by the subsequent input_validation step, aligning with the Kubernetes Mutating → Validating admission order.

Fixed

  • Middleware transforms now validated — Previously, middleware modifications to inputs were either never re-validated (production code) or silently discarded (pipeline abstraction). The step order swap ensures transformed inputs pass schema validation.

[0.16.0] - 2026-04-03

Added

Config Bus Enhancements

  • env_style parameter — Three modes for environment variable key conversion: auto (default, matches against defaults tree), nested (single _.), flat (no conversion). Resolves flat snake_case config key conflicts.
  • max_depth parameter — Limits nesting depth for env var key conversion (default: 5). Prevents excessively deep nesting from long env var names.
  • env_prefix auto-derivation — When env_prefix is not provided, auto-derived from namespace name via name.upper().replace("-", "_").
  • env_map parameter — Explicit mapping of bare (unprefixed) env var names to config keys within a namespace (e.g., {"REDIS_URL": "cache_url"}).
  • Config.env_map() class method — Global bare env var → top-level config key mapping (e.g., {"PORT": "port"}).
  • CONFIG_ENV_MAP_CONFLICT error — Raised when the same env var is claimed by multiple env_map registrations.

Context Redesign

  • ContextKey<T> typed accessor — Generic type-safe wrapper for context.data access with get(), set(), delete(), exists(), scoped() methods. Available in Python, TypeScript, and Rust.
  • Built-in context key constantsTRACING_SPANS, TRACING_SAMPLED, METRICS_STARTS, LOGGING_START, REDACTED_OUTPUT, RETRY_COUNT_BASE exported for middleware authors.
  • _context_version serialization — Context serialization now includes _context_version: 1 for forward compatibility. Deserialization warns on unknown versions but proceeds.
  • Context serialize() / deserialize() methods — Explicit serialization API with data key filtering (underscore-prefixed keys excluded).

Annotations Extension

  • extra field on ModuleAnnotations — Free-form extension dictionary for ecosystem packages and user metadata (e.g., extra={"mcp.category": "tools"}).
  • pagination_style type relaxed — Changed from Literal["cursor", "offset", "page"] to open string, allowing custom pagination strategies.
  • DEFAULT_ANNOTATIONS constant — Exported frozen default annotations instance.
  • from_dict() classmethod (Python) — Deserializes annotations with unknown keys captured in extra.
  • createAnnotations() factory (TypeScript) — Convenience factory accepting partial overrides.
  • Canonical snake_case wire format (TypeScript) — annotationsToJSON() / annotationsFromJSON() for cross-language serialization.

ACL Condition Handlers

  • ACLConditionHandler protocol — Extensible condition evaluation interface. Python: sync + async protocols. TypeScript: boolean | Promise<boolean>. Rust: #[async_trait].
  • ACL.register_condition() class method — Register custom condition handlers (e.g., ip_range, time_window).
  • $or and $not compound operators — Built-in compound condition handlers for OR and NOT logic in ACL rules.
  • async_check() method — Async ACL check alongside existing sync check(), supporting async condition handlers.
  • Fail-closed for unknown conditions — Unknown condition keys now log a warning and return False (deny), instead of being silently ignored.

Execution Pipeline Strategy

  • Step protocol / interface / trait — Pluggable pipeline step with name, description, removable, replaceable, and async execute().
  • ExecutionStrategy class — Ordered list of steps with insert_after(), insert_before(), remove(), replace() modification API.
  • PipelineEngine — Executes strategy steps with index-based loop, skip_to support, trace accumulation, and abort handling.
  • PipelineTrace / StepTrace — Complete execution trace for AI introspection and learning.
  • 11 built-in stepsBuiltinContextCreation, BuiltinSafetyCheck, BuiltinModuleLookup, BuiltinACLCheck, BuiltinApprovalGate, BuiltinInputValidation, BuiltinMiddlewareBefore, BuiltinExecute, BuiltinOutputValidation, BuiltinMiddlewareAfter, BuiltinReturnResult.
  • Preset strategiesbuild_standard_strategy() (11 steps), build_internal_strategy() (skip ACL/approval), build_testing_strategy() (minimal), build_performance_strategy() (skip middleware).
  • Executor.strategy parameter — Optional, backward-compatible. When omitted, uses standard 11-step pipeline.
  • call_with_trace() / call_async_with_trace() — Returns (result, PipelineTrace) tuple for observability.
  • register_strategy() / list_strategies() / describe_pipeline() — Strategy introspection API.

Changed

  • Data key naming convention — Internal middleware keys migrated from legacy names (_metrics_starts, _usage_starts, _obs_logging_starts) to _apcore.mw.* convention. All middleware now uses typed ContextKey constants.

Fixed

  • Rust ApprovalRequest spec alignment — Added required context field (Option<Context<Value>>) and changed annotations type from HashMap to ModuleAnnotations per spec §7.3.1.
  • Rust DependencyInfo field rename — Renamed name to module_id for cross-SDK consistency with Python/TypeScript.
  • Rust config env fallback — Fixed namespace-mode APCORE_* env var fallback to resolve to top-level paths instead of incorrectly prepending apcore. prefix.
  • Rust config_env conformance test — Added missing conformance test (was 9/10, now 10/10 fixtures).
  • Rust Context field alignment — Removed non-spec fields (created_at, parent_trace_id, trace_context). Changed global_deadline from Option<Instant> to Option<f64> (epoch seconds).
  • Rust Identity immutability — Fields made private with pub getters. Serde compatibility via IdentityRaw pattern.
  • TypeScript globalDeadline field — Added globalDeadline: number | null to Context (was missing).
  • Rust system.control module — Extracted into dedicated control.rs file (was inline in mod.rs).
  • TypeScript removeRule comparison — Fixed to use element-wise array comparison instead of JSON.stringify.
  • Rust empty callers matching — Empty callers list now matches none (aligned with Python/TypeScript).
  • API documentation audit (13 fixes) — Corrected Executor constructor (missing strategy param), cache_key_fields type (tuple not list), ModuleAnnotations.extra field, Reserved Words, extensions_dir default, ModuleExample defaults, Context serialize()/deserialize(), _global_deadline, preflight()/describe() methods, PreflightCheckResult.warnings, $or/$not ACL examples.
  • Cross-language tabbed examples — Added TypeScript tab to system-modules.md, converted client-api.md to 3-language tabs, fixed bare code blocks in 4 API docs.

[0.15.1] - 2026-03-31

Changed

  • Env prefix convention simplified — Removed the ^APCORE_[A-Z0-9] reservation rule from namespace registration. Sub-packages now use single-underscore prefixes (APCORE_MCP, APCORE_OBSERVABILITY, APCORE_SYS) instead of the double-underscore form. The longest-prefix-match dispatch algorithm already disambiguates correctly; the previous restriction was unnecessary.
  • Built-in namespace env prefixes: APCORE__OBSERVABILITYAPCORE_OBSERVABILITY, APCORE__SYSAPCORE_SYS.

[0.15.0] - 2026-03-29

Added

Protocol Specification (v1.6.0-draft)

  • Config Bus Architecture (§9.4) — apcore.Config upgraded from internal configuration tool to ecosystem-level Config Bus. Any package (apcore ecosystem or third-party) can register a namespace with optional JSON Schema validation, environment variable prefix, and defaults. Design principles: bus not center, zero-cost adoption, gradual integration, cross-language consistency, strict/flexible coexistence
  • Namespace Registration (§9.5)Config.register_namespace(name, schema, env_prefix, defaults) API with cross-language examples (Python, TypeScript, Rust, Go, Java). Global (class-level) registry shared across Config instances. Late registration permitted with explicit reload() to apply. No unregister_namespace in this version
  • Unified Configuration File (§9.6) — Single YAML file with namespace-partitioned sections. Automatic mode detection: legacy mode (no apcore: key, fully backward compatible) vs namespace mode (apcore: key present). _config reserved namespace for meta-configuration (strict, allow_unknown)
  • Mount Mechanism (§9.7)config.mount(namespace, from_file|from_dict) for attaching external configuration sources without requiring a unified file. Primary integration path for third-party projects with existing config systems
  • Per-Namespace Env Override (§9.8) — Each namespace declares its own env_prefix. Longest-prefix-match dispatch algorithm resolves ambiguity. APCORE_MCP double-underscore convention for apcore sub-packages to avoid collision with APCORE_ prefix. Compatibility note for apflow's simpler env convention
  • Namespace-Aware Access API (§9.9)config.get("namespace.key.path") with dot-path namespace resolution algorithm. config.namespace(name) for full subtree retrieval. config.bind(ns, type) / config.get_typed(path, type) for typed access. Config.registered_namespaces() for introspection
  • Validation Algorithm A12-NS (§9.10) — Extended A12 for namespace mode: validates apcore namespace with original algorithm, validates registered namespaces against their JSON Schema, handles unknown namespaces per strict/allow_unknown settings
  • Hot-Reload Namespace Support (§9.11)config.reload() re-reads YAML, re-detects mode, re-applies namespace defaults and env overrides, re-validates, and re-reads mounted files
  • Cross-Language Implementation Requirements (§9.12) — MUST/SHOULD API surface table, language-idiomatic naming matrix, thread safety requirements, parameter passing style note
  • Ecosystem Integration Patterns (§9.13) — Convention table for all apcore packages (namespace, env prefix, schema file). Third-party defensive integration pattern. Framework auto-registration examples (Django AppConfig.ready(), FastAPI module-level, NestJS)
  • Config Discovery (§9.14) — Optional (MAY) automatic config file discovery with search order: $APCORE_CONFIG_FILE./project.yaml./apcore.yaml → user-level config
  • New error codesCONFIG_NAMESPACE_DUPLICATE, CONFIG_NAMESPACE_RESERVED, CONFIG_ENV_PREFIX_CONFLICT, CONFIG_MOUNT_ERROR, CONFIG_BIND_ERROR

Cross-Package Consistency (§8.8, §9.15, §9.16)

Three mechanisms addressing ecosystem consistency across apcore, apcore-mcp, apcore-cli, apcore-a2a and third-party packages:

  • Error Formatter Registry (§8.8) — Shared ErrorFormatter protocol and registration point. apcore-mcp and apcore-a2a each independently implement protocol-specific error mappers (MCP camelCase/sanitization, A2A JSON-RPC code mapping); this registry makes the contract explicit and discoverable. Adoption is SHOULD-level for ecosystem adapters — apcore does not ship adapter-specific formatters. New error code: ERROR_FORMATTER_DUPLICATE

  • apcore Built-in Namespace Registrations (§9.15) — The framework pre-registers two namespaces for its own subsystems, applying the Config Bus pattern to apcore's own internal configuration. Both promote existing flat keys already present in apcore-python's config.py; migration is 1:1 with no breaking changes:

    • observability (APCORE_OBSERVABILITY) — Extracts apcore.observability.* flat keys (tracing, metrics, logging, error_history, platform_notify) into a dedicated namespace. Adapter packages (apcore-mcp, apcore-a2a, apcore-cli) should read from this namespace rather than using independent logging defaults
    • sys_modules (APCORE_SYS) — Promotes apcore.sys_modules.* flat keys into a dedicated namespace. register_sys_modules() prefers config.namespace("sys_modules") in namespace mode with config.get("sys_modules.*") legacy fallback
  • Event Type Naming and Collision Fix (§9.16) — Resolves two confirmed collisions in apcore-python's emitted event types:

    • "module_health_changed" was used for two distinct events (toggle on/off vs. error rate recovery); replaced by canonical names apcore.module.toggled and apcore.health.recovered
    • "config_changed" was used for two distinct events (key update vs. module reload); replaced by apcore.config.updated and apcore.module.reloaded
    • Establishes dot-namespaced naming convention: apcore.* reserved for core, apcore-mcp.* / apcore-a2a.* / apcore-cli.* for adapters
    • All four legacy short-form names remain emitted as aliases during transition

[0.14.0] - 2026-03-24

Fixed

Specification Documents

  • docs/features/event-system.md — Fixed severity levels from warning/critical to warn/fatal, aligning with PROTOCOL_SPEC §10.2
  • docs/features/core-executor.md — Fixed Identity description: removed incorrect permissions field, corrected to id, type, roles, attrs per PROTOCOL_SPEC §5.7
  • docs/features/core-executor.md — Added missing ApprovalPendingError to Approval Gate description per PROTOCOL_SPEC §7.4
  • docs/features/middleware-system.md — Added retry.py to Key Files table (was described in Components but missing from file list)
  • docs/features/observability.md — Added concrete metric names (apcore_module_calls_total, apcore_module_errors_total, apcore_module_duration_seconds) to convenience method documentation

Protocol Specification

  • Fixed duplicate ### 10.5 section numbering — renumbered Sensitive Data Redaction to §10.6, Sampling Strategy to §10.7, Span Naming Convention to §10.8
  • Fixed ### 8.1.1 misnumbered under §9.1 — corrected to §9.1.1
  • Fixed ### 10.8.x misnumbered under §11.8 — corrected to §11.8.1–§11.8.4
  • Fixed middleware priority model contradiction between §11.2 (explicit 0-1000) and §12 (registration order) — §12 now aligns with §11.2
  • Fixed on_error examples in README.md and concepts.md — changed raise error to correct return-based contract (return None / return dict)
  • Updated "Last Updated" date to 2026-03-24

Scope Document

  • Added Approval System (§7) and Event System to "Core Protocol Includes" list in SCOPE.md

README

  • Added AI-Perceivable brand definition block to README header
  • Added "Perceived → Understood → Executed" progression table to "Why AI-Perceivable?" section

Ecosystem

§5.13 Display Overlay (specified in v0.13.0) is now implemented across the official adapter stack:

Package Version What was implemented
apcore-toolkit 0.4.0 DisplayResolver — §5.13 resolve priority chain, MCP alias sanitization/64-char limit, CLI alias validation, suggested_alias fallback, binding_path file/directory loading
apcore-cli 0.3.0 CLI command routing from metadata["display"]["cli"]["alias"]; descriptor cache; JSON output reads display overlay
apcore-mcp 0.11.0 MCP tool name and description from metadata["display"]["mcp"]; guidance appended to tool description
apcore-a2a 0.3.0 A2A skill id/description/tags from metadata["display"]["a2a"]; removed dead _build_extensions()
fastapi-apcore 0.4.0 binding_path parameter on create_cli() / create_mcp_server(); DeprecationWarning for simplify_ids=True
  • §5.14 Convention Module Discovery — new optional protocol capability for zero-decorator module registration via commands/ directory convention. Supports cross-language function discovery with schema inference from type annotations.

[0.13.1] - 2026-03-22

Changed

  • Rebrand: aipartnerup → aiperceivable

[0.13.0] - 2026-03-12

Added

Protocol Specification

  • Caching annotations (§4.4) — cacheable, cache_ttl, cache_key_fields annotation fields for AI-aware caching decisions
  • Pagination annotations (§4.4) — paginated, pagination_style (cursor/offset/page) for paginated result handling
  • AI Metadata Conventions (§4.6) — 13 standardized x- metadata keys across 4 categories: Intent, Planning, Performance/Cost, Trust/Verification
  • sunset_date (§5.2) — ISO 8601 date field for module deprecation lifecycle
  • on_suspend() / on_resume() (§5.6, §12.7.3) — Optional lifecycle hooks for state preservation during hot-reload
  • Hot Reload with State Migration (§12.7.3) — New section with algorithm, constraints, and Python example
  • Ecosystem documentation — Added apcore-mcp, apcore-a2a, apcore-cli, and apcore-testing to README and SCOPE

Schema

  • module-meta.schema.json — Added streaming, cacheable, cache_ttl, cache_key_fields, paginated, pagination_style, sunset_date definitions

Changed

  • Rebranded from "universal module development framework" to "AI-Perceivable module standard" with three-tier messaging (slogan/subtitle/full definition)
  • SCOPE.md — Expanded boundary decisions from 17 to 26 rows; updated requirements wording from "The framework" to "Implementations"
  • Section renumbering — §12.7.3–12.7.8 renumbered after hot-reload insertion
  • Lifecycle table (§12.7.1) — Reordered to show on_resume after on_load, with note clarifying old/new instance distinction

Fixed

  • Cross-references — Fixed 10+ stale §11.7.x and §12.7.x references across architecture, registry-api, algorithms, and context-object docs
  • Metadata key consistency — Aligned x-max-latency-ms description between README and PROTOCOL_SPEC

[0.12.0] - 2026-03-10

Added

Protocol Specification (v1.5.0-draft)

  • Error catalog expanded (§8.2) — Added MODULE_DISABLED, EXECUTION_CANCELLED, RELOAD_FAILED error codes with retryability classification and error hierarchy entries
  • UsageCollector formalized (§10.4) — Added usage tracking specification for UsageCollector and UsageMiddleware backing system.usage.* modules

Conformance

  • Shared conformance fixtures (conformance/fixtures/) — 7 JSON fixture files for cross-language testing: pattern_matching, specificity, normalize_id, call_chain, error_codes, version_negotiation, acl_evaluation

Changed

Protocol Specification

  • Context.child() naming (§12.7.2) — Standardized derive()child() to match SDK implementations
  • Forward-declared errors resolvedGENERAL_NOT_IMPLEMENTED and DEPENDENCY_NOT_FOUND now fully implemented in both SDKs

Documentation

  • Cross-reference links — Added links between API docs and feature docs (executor-api.md, context-object.md)
  • Conformance known deviations — Updated status of error code implementations

Fixed

  • CHANGELOG count corrections — System modules 10→9, APCore client methods 19→17
  • Phantom entry removed — TypeScript batchProcessing annotation (never implemented)

[0.11.0] - 2026-03-09

Added

Documentation — New Files

  • APCore Client API (docs/api/client-api.md) — Full API reference for the unified APCore client covering 17 public methods: call, call_async, stream, validate, module, register, discover, list_modules, describe, use, use_before, use_after, remove, on, off, disable, enable, plus events/registry/executor properties and global apcore.* entry points
  • Event System (docs/features/event-system.md) — EventEmitter, ApCoreEvent, EventSubscriber protocol, WebhookSubscriber (retry strategy), A2ASubscriber (auth modes), subscriber type factory registry, event types table, and YAML configuration reference
  • System Modules (docs/features/system-modules.md) — Complete reference for 9 built-in system.* modules with input/output schemas: system.health.summary, system.health.module, system.manifest.module, system.manifest.full, system.usage.summary, system.usage.module, system.control.update_config, system.control.reload_module, system.control.toggle_feature; plus register_sys_modules() setup guide and YAML configuration

Documentation — Updated Files

  • Observability featuresErrorHistory, UsageCollector, PlatformNotifyMiddleware documented
  • Middleware guide — Built-in RetryMiddleware + RetryConfig reference added
  • Schema systemSchemaStrategy and ExportProfile enums documented

Changed

Documentation — Updated Files

  • docs/getting-started.md — Added §8 Global Entry Points (16 apcore.* module-level functions) and §9 System Modules quick start with health/usage/manifest examples
  • docs/api/executor-api.mdvalidate() return type updated from ValidationResult to PreflightResult with 6-check breakdown and requires_approval flag; call()/call_async()/stream() signatures updated with version_hint parameter; added ModuleDisabledError, ModuleTimeoutError, ReloadFailedError, FeatureNotImplementedError, DependencyNotFoundError to error types; timeout section rewritten for dual-timeout model with cooperative cancellation (CancelToken + 5s grace period)
  • docs/api/registry-api.md — Added disable()/enable() module toggle, safe_unregister() with cooperative drain (Algorithm A21), acquire() context manager, is_draining(), describe() for AI/LLM tool discovery, negotiate_version() (Algorithm A14)
  • docs/features/observability.md — Added three subsystems: ErrorHistory (ring buffer with deduplication, ErrorEntry dataclass), UsageCollector (hourly bucketed storage, trend computation, UsageMiddleware), PlatformNotifyMiddleware (threshold-based alerting with hysteresis)
  • docs/guides/middleware.md — Replaced hand-written RetryMiddleware example with built-in RetryMiddleware + RetryConfig reference (exponential/fixed backoff, jitter, retryable-only); added §5.5–5.7 cross-references for ErrorHistoryMiddleware, UsageMiddleware, PlatformNotifyMiddleware
  • docs/features/schema-system.md — Added SchemaStrategy enum (yaml_first, native_first, yaml_only) and ExportProfile enum (mcp, openai, anthropic, generic)
  • docs/features/core-executor.md — Added dual-timeout model (global deadline + per-module), cooperative cancellation with CancelToken, deep merge for streaming (depth cap 32), error propagation via propagate_error() (Algorithm A11), PreflightResult validation
  • docs/README.md — Added client-api.md, event-system.md, system-modules.md to directory tree, API reference table, feature specifications table, and concept index

[0.10.0] - 2026-03-07

Added

Protocol Specification

  • IDConverter implementation note (§12.2) — SDKs MAY implement as utility function instead of class
  • MiddlewareManager split-method pattern (§12.2) — SDKs MAY use execute_before/execute_after/execute_on_error instead of unified run_chain()
  • Module.stream() optional method (§5.6) — Documented stream() as optional method in Module interface for streaming support

Error Hierarchy

  • DependencyNotFoundError — New error class for DEPENDENCY_NOT_FOUND code (previously forward-declared)
  • FeatureNotImplementedError (Python) / NotImplementedError (TypeScript) — New error class for GENERAL_NOT_IMPLEMENTED code (previously forward-declared)

Changed

  • Executor pipeline docs — All references updated from "10-step" to "11-step" pipeline across README.md, docs/features/core-executor.md, docs/api/executor-api.md
  • Context logger property — Upgraded from SHOULD to MUST in docs/api/context-object.md (both SDKs already provide it)
  • docs/api/module-interface.md — Added optional stream() method documentation
  • **docs/getting-started.md** — Rewritten to recommend APCore` unified client as primary approach

[0.9.0] - 2026-03-06

Added

Protocol Specification

  • Executor.validate() preflight (§12.2) — [SHOULD] non-destructive preflight check through Steps 1–6 without invoking module code or middleware; new PreflightResult / PreflightCheckResult types with duck-type ValidationResult compatibility
  • §12.8 Executor.validate() Cross-Language Implementation Guide — error handling mapping, type mapping for Python/TypeScript/Go/Rust/Java/C/C++, schema library requirements, naming conventions
  • Preflight Tests added to §12.4 Consistency Test Suite (7 test cases)
  • Context optional extension fieldscancel_token, services, redacted_inputs with serialization rules (§5.7)
  • New error codesCONFIG_NOT_FOUND, CONFIG_INVALID, SCHEMA_CIRCULAR_REF, BINDING_FILE_INVALID, MIDDLEWARE_CHAIN_ERROR, VERSION_INCOMPATIBLE, ERROR_CODE_COLLISION, CIRCULAR_DEPENDENCY, DEPENDENCY_NOT_FOUND (§8)
  • New error classesBindingFileInvalidError, MiddlewareChainError, VersionIncompatibleError, ErrorCodeCollisionError added to error hierarchy
  • AI error guidance fieldsretryable, ai_guidance, user_fixable, suggestions for improved LLM agent error handling
  • AI intent metadata keys (§4.6) — x-when-to-use, x-when-not-to-use, x-common-mistakes, x-workflow-hints conventions for LLM agents
  • TypeScript and C/C++ added to §12.6 Language-Specific Implementation Notes

Changed

  • PROTOCOL_SPEC.md — bumped to v1.4.0-draft
  • Executor pipeline renumbered from 10 steps (with Step 4.5) to clean 11 steps — Approval Gate is now Step 5, subsequent steps shifted +1
  • §7.4, §7.9, streaming protocol references updated to match new 11-step numbering
  • Executor.validate() preflight added to §12.3 cross-language requirements table
  • Section cross-references fixed — §11.7→§12.7, §10.3→§11.3, §9.7→§10.7, §7→§8 (error code references)
  • Retryability table updated with new error codes; MIDDLEWARE_CHAIN_ERROR removed from forward-declared list
  • docs/api/context-object.md — added optional extension fields documentation
  • docs/api/executor-api.md — added AI error guidance fields and new error types
  • docs/concepts.md — expanded AI collaboration and cognitive interface concepts
  • SCOPE.md — updated to reflect new features

[0.8.0] - 2026-03-03

Added

  • AI Collaboration Lifecycle documentation: Integrated description, metadata, requires_approval, and ai_guidance into a unified narrative (Discovery, Strategy, Governance, Recovery).
  • New "Cognitive Interface" concept in README.md and docs/concepts.md.
  • Intent-oriented design tips in docs/guides/creating-modules.md.
  • Comprehensive multi-language "Getting Started" guide covering both Python and TypeScript side-by-side
  • Multi-language support (side-by-side examples) in "Creating Modules" guide
  • Unified documentation links across all implementation READMEs (apcore-python, apcore-typescript)
  • TypeScript examples for Registry, Executor, and Module definition in core documentation

Changed

  • README.md — Updated Quick Start with multi-language tabs and links to the new Getting Started guide

[0.7.0] - 2026-03-01

Added

Protocol Specification

  • Approval System (§7) — new section in PROTOCOL_SPEC.md defining the ApprovalHandler protocol, ApprovalRequest/ApprovalResult data types, Executor Step 4.5 integration, error types (APPROVAL_DENIED, APPROVAL_TIMEOUT, APPROVAL_PENDING), built-in handlers, protocol bridge handlers, phased implementation (Phase A sync, Phase B async), and conformance levels

Feature Documentation

  • docs/features/approval-system.md — full specification of the Approval System feature

Changed

  • PROTOCOL_SPEC.md — bumped to v1.3.0-draft; added "Recommended AI Intent Metadata Keys" (§4.6) outlining x-when-to-use, x-when-not-to-use, x-common-mistakes, and x-workflow-hints conventions for LLM agents; updated requires_approval annotation description to reference runtime enforcement; added approval error codes and error hierarchy; renumbered §7–§13 → §8–§14
  • docs/api/executor-api.md — added approval_handler constructor parameter, ApprovalDeniedError/ApprovalTimeoutError to error types, Step 4.5 to execution flow and state machine
  • docs/api/module-interface.md — updated requires_approval annotation description to reference Approval System and runtime enforcement
  • docs/features/core-executor.md — added Step 4.5 (Approval Gate) to the execution pipeline
  • docs/README.md — added Approval System to feature specifications table, directory tree, and concept index

[0.6.0] - 2026-02-23

Added

Protocol Specification

  • Streaming execution protocol — new section in PROTOCOL_SPEC.md defining streaming execution model for long-running or real-time module outputs
  • Module ID constraints & naming conventions — formal rules for module identifiers added to the protocol spec
  • SDK export requirements — specified required exports for conformant SDK implementations
  • Module interface improvements — refined module interface contracts with streaming semantics

Feature Documentation

  • docs/features/acl-system.md — full specification of the Access Control List system
  • docs/features/core-executor.md — detailed documentation of the execution pipeline
  • docs/features/decorator-bindings.md — guide on the @module decorator and binding mechanics
  • docs/features/middleware-system.md — composable middleware pipeline specification
  • docs/features/observability.md — tracing, metrics, and structured logging documentation
  • docs/features/registry-system.md — module registry and discovery system documentation
  • docs/features/schema-system.md — schema-driven module input/output validation documentation

CI/CD & Site

  • GitHub Actions workflow (.github/workflows/deploy-docs.yml) for automated documentation deployment
  • MkDocs configuration (mkdocs.yml) for the documentation site

Changed

  • README — enhanced with unified SDK explanation, TypeScript SDK implementation examples, and updated architecture overview
  • SCOPE.md — updated to reflect current project scope and feature set
  • docs/concepts.md — rewritten with unified SDK explanation for multi-language context
  • docs/architecture.md — updated to align with protocol spec changes
  • docs/api/ — updated context-object.md, executor-api.md, module-interface.md, and registry-api.md with version-aligned content
  • docs/spec/conformance.md — revised conformance requirements to match 0.2.0 protocol spec
  • docs/guides/adapter-development.md — updated adapter development guide
  • mkdocs.yml — navigation structure updated to include new feature pages

Removed

  • ROADMAP.md — removed; roadmap references updated across documentation
  • docs/guides/creating-modules-translated.md — removed translated guide from navigation

[0.5.0] - 2026-02-22

Added

Protocol Specification

  • Level 2 Conformance (Phase 1) — Extension system, Async Task Management, and W3C Trace Context support added to protocol requirements
  • Extension System (§12.2) — Unified extension point framework for pluggable components (discoverers, middleware, ACL, exporters)
  • Async Task Management (§12.7.3) — Standardized lifecycle for background tasks (Pending, Running, Completed, Failed, Cancelled)
  • Trace Context (§12.7.4) — W3C Trace Context (traceparent) support for distributed tracing propagation
  • Async Middleware Protocol — Requirements for non-blocking middleware dispatch

[0.4.0] - 2026-02-20

Added

Protocol Specification

  • Streaming Support — Formalized ModuleAnnotations.streaming and Executor.stream() behavior in protocol specification
  • Shallow Merge for Streaming — Algorithm for accumulating streaming chunks for output validation and post-processing

[0.3.0] - 2026-02-20

Added

Protocol Specification

  • ErrorCodes Catalog — Standardized error code constants (replaces hardcoded strings)
  • ContextFactory Protocol — Interface for creating Context from platform-specific requests
  • Registry Constants — Standardized module ID patterns and event types
  • Comprehensive Schema System — Formalized schema loading, validation, and multi-profile export (MCP, OpenAI, Anthropic) requirements

[0.2.0] - 2026-02-16

Changed

Protocol Specification

  • Module ID Validation — Strengthened pattern to ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ (lowercase, digits, underscores, dots; no hyphens)
  • Registry Event Constants — Standardized event names for module registration lifecycle

[0.1.0] - 2026-01-01

Initial Release

Added

Core Framework

  • Schema-driven modules - Define modules with Pydantic input/output schemas and automatic validation
  • @module decorator - Zero-boilerplate decorator to turn functions into schema-aware modules
  • Executor - 10-step execution pipeline with comprehensive safety and security checks
  • Registry - Module registration and discovery system with metadata support

Security & Safety

  • Access Control (ACL) - Pattern-based, first-match-wins rule system with wildcard support
  • Call depth limits - Prevent infinite recursion and stack overflow
  • Circular call detection - Detect and prevent circular module calls
  • Frequency throttling - Rate limit module execution
  • Timeout support - Configure execution timeouts per module

Middleware System

  • Composable pipeline - Before/after hooks for request/response processing
  • Error recovery - Graceful error handling and recovery in middleware chain
  • LoggingMiddleware - Structured logging for all module calls
  • TracingMiddleware - Distributed tracing with span support for observability

Bindings & Configuration

  • YAML bindings - Register modules declaratively without modifying source code
  • Configuration system - Centralized configuration management
  • Environment support - Environment-based configuration override

Observability

  • Tracing - Span-based distributed tracing integration
  • Metrics - Built-in metrics collection for execution monitoring
  • Context logging - Structured logging with execution context propagation

Async Support

  • Sync/Async modules - Seamless support for both synchronous and asynchronous execution
  • Async executor - Non-blocking execution for async-first applications

Developer Experience

  • Type safety - Full type annotations across the framework
  • Comprehensive tests - 90%+ test coverage with unit and integration tests
  • Documentation - Quick start guide, examples, and API documentation
  • Examples - Sample modules demonstrating decorator-based and class-based patterns