Skip to content

Release 0.30.0 (2026-09-06)

Choose a tag to compare

@tercel tercel released this 06 Sep 06:48
· 85 commits to main since this release

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_unset — bindings.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 target → target_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.