Repository navigation
Release 0.30.0 (2026-09-06)
Ships
PROTOCOL_SPECv1.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 whichAPCORE_*variables carry paths, because a relative value silently re-roots wherever the working directory differs.apcore-clihad 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.jsonandschemas/defaults.schema.jsoncarry"x-apcore-path": trueon 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.patternis a glob matched against filenames withinbindings.dirand is never resolved as a path itself, andextensions.rootsis list-valued with no scalar environment encoding — an implementation MUST NOT invent a delimiter-separatedAPCORE_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.rootresolves against the config file's directory (ACL.discover, D-64) whileschema.rootresolves 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 fixtureconformance/fixtures/config_path_typed_keys.json(7 cases), whose discriminating case isbindings.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, soexport 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 forAPCORE_CONFIG_FILE(#88), where an empty value injected a phantomconfig.filekey; 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
warningsfilter, 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.jsongains thebindingssection it never had.bindings.dir(./bindings) andbindings.pattern(*.binding.yaml) were declared in §9.1.1 and inapcore-config.schema.json'sBindingsConfig, 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." Becauseconformance/fixtures/config_key_governance.jsonpins each SDK's default table to that schema, no SDK could carry the default — all three had to hardcode./bindingsat the loader instead, which is the same declared-in-one-place-unreachable-from-the-mechanism defect as the rest of this batch.bindings.dircarriesx-apcore-path: truelike its three sibling roots.config_key_governance.jsonregenerated: 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.rootresolves against the configuration file's directory (ACL.discover, decision D-64,docs/features/acl-system.md) whileschema.rootandextensions.rootresolve against the process CWD, unconditionally. Two sibling keys, identical relative values, identicalAPCORE_*override syntax, different answers, and neither behaviour violated anything, because the specification had no opinion.
The project root is declared. EveryConfighas 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 theConfighas 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, becauseextensions.root: ./extensionsin~/.config/apcore/config.yamlcannot 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 isacl.root— the security key — that has it. A config at~/.config/apcore/config.yamlcarryingacl.root: ./aclloads 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 resolvesextensions.rootagainst 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./extensionsin 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 perConfig(an implementation MUST NOT resolve two path-typed keys of the sameConfigdifferently), 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=./xset 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_rootalready equals CWD. Tiers 6-7 —acl.rootmoves 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, whereschema.root,extensions.rootandbindings.dirmove 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.mdopen 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 wholeConfigleaves 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 fixtureconformance/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_unchangedpins the deprecation phase itself: under a tier-1 config outside CWD,acl.rootstill resolves file-relative andschema.rootstill resolves against CWD, withacl/andschemas/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'sACL.discoverSide 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_resolutiongainsenv_var_must_not_be_read_directly_at_the_loader— §5.12.6 clause 2 had no case at all, so an implementation reading the rawAPCORE_BINDINGS_DIR(precisely the apcore-typescript#36 defect the clause exists to forbid) passed the entire fixture; the new case sets the variable after theConfigis 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_dirreported["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 hardcodingfakehome/.config/apcore— §9.14 states tier 6 is platform-varying (XDG on Linux,~/Library/Application Supporton macOS), so the literal spelling made every driver fail on macOS while asserting nothing extra on Linux.no_warning_when_all_path_values_absolutenow spells every §9.2.1 key absolutely; as first published it set onlyschema.rootandacl.root, leavingextensions.rootandbindings.dirat 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 "ifbindings.diris configured, implementations MUST scan files matchingpatternin 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.diris 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, whileBindingLoaderis 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 setbindings.dirinapcore.yamlgot no scan in any SDK; a user who exportedAPCORE_BINDINGS_DIRgot one in TypeScript only, which had implemented the environment tier alone of the key's precedence chain via a rawprocess.envread atbindings.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 frombindings.dirunder §9.2 precedence —APCORE_BINDINGS_DIR> configuration file > default"./bindings"— and MUST match candidate files againstbindings.patternthrough 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 readAPCORE_BINDINGS_DIRdirectly at the loader — the environment tier arrives through the ordinaryAPCORE_*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./bindingsdirectory. 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 thebindings.fileswithdrawal recorded in the same section: that key was schema-invalid (BindingsConfigisadditionalProperties: falseover{dir, pattern}), absent from every key surface, and implemented nowhere, so there was nothing to wire.bindings.diris declared by the canonical schema with a default, present in all three key surfaces and inconfig_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 fixtureconformance/fixtures/bindings_dir_resolution.json(8 cases). Its discriminating case isconfig_file_dir_is_scanned_with_env_unset—bindings.dirin a config file,APCORE_BINDINGS_DIRunset, 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_initpins 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 fieldtarget_ida MUST; the canonical schema, the companion spec, both binding fixtures and all three SDKs usetarget(#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'sResolvedModuletype, §8.2 / §8.6 / §8.7 error descriptions, §5.14.6 and §9.15 prose) where a past over-appliedtarget→target_idrename had left the wrong spelling standing, including in ordinary English ("Deploy application to target_id environment"). The 16 remainingtarget_idoccurrences are ACLcaller_id/target_idand schema$refcontexts, which are correct. No implementation changes and no deployed file is affected: the population of binding files usingtarget_idis empty, because such a file has never loaded.conformance/fixtures/bindings_dir_resolution.jsoncorrected 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 missingacl.rootmust 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.