You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
APCORE_ACL_ROOT=./x resolves against the config file's directory while APCORE_SCHEMA_ROOT=./x resolves against CWD, and no rule says either is wrong #113
Revised 2026-09-06 (second revision). Option B was wrong and is replaced by B′. Step C and B′'s deprecation phase are both landed — spec v1.36.0 §9.2.1/§9.2.2, and all three SDKs. What remains open is only the v2.0 switch. The migration-cost estimate below has been corrected: it was too optimistic, and the evidence is now measured rather than assumed.
Summary
Two sibling path keys, identical relative values, identical override syntax, different bases — in shipped code today:
Set
Config file at config-dir/apcore.yaml, process CWD is run-dir/
Resolves to
APCORE_ACL_ROOT=./x
ACL.discover resolves against config.source_path's directory
config-dir/x
APCORE_SCHEMA_ROOT=./x
SchemaLoader resolves against process CWD
run-dir/x
Evidence:
apcore-python src/apcore/acl.py:1479-1489 — discover() reads the merged value via config.get("acl.root", …), then resolves a relative result against Path(config.source_path).resolve().parent when the config has a source path, else CWD. The merged value carries no record of which tier produced it, so an env-sourced ./x is resolved as though it had been written in the file.
apcore-python src/apcore/schema/loader.py:503 — Path(config.get("schema.root", …)).resolve(), i.e. CWD-relative, unconditionally. apcore-typescript src/schema/loader.ts:45 (resolve(...)) and apcore-rust src/schema/loader.rs:64 (PathBuf::from, joined later) match.
Neither behaviour violates the spec, because the spec does not have an opinion.
Root cause
§9.1.1 gives all four path-valued keys a relative default — extensions.root: "./extensions", schema.root: "./schemas", acl.root: "./acl", bindings.dir: "./bindings" — and neither §9.1.1, §9.2, nor §9.3 (which asks only for "a valid directory path") states what a relative value is relative to.
One base has been written down, in a feature contract rather than the protocol spec: docs/features/acl-system.md:356 (decision D-64) specifies config-file-relative-else-CWD for acl.root. That is the rule ACL.discover implements, and it is why acl.root is the key that diverges from its three siblings.
Why Option B was replaced
The original proposal — "file-declared values resolve against the config file's directory, env/API values against CWD" — does not survive contact with §9.14's discovery order:
That order splits the question into three cases, only one of which is contested:
Tiers 2-5 (the overwhelmingly common case): the config file's directory is CWD. Options A and B are indistinguishable.
Tier 1 with a config outside CWD: they differ, and file-relative is the better answer.
Tiers 6-7: file-relative is wrong. extensions.root: ./extensions in ~/.config/apcore/config.yaml would resolve to ~/.config/apcore/extensions. A user-level config's relative paths are per-project by intent; they cannot mean "next to the config file".
This is not hypothetical — it is live today in acl.root, the one key already implementing B-like behaviour. Reproduced with a user-level config and CWD set to a project directory:
CWD : …/t113/project
source_path : ../fakehome/.config/apcore/config.yaml
acl.root (raw) : ./acl
ACL discovered? : True ← loaded from ~/.config/apcore/acl/, not the project
warning : extensions.auto_discover=true but extensions.root './extensions' does not exist
Read the last two lines together: one config document, one load, two bases.acl.root resolved next to the config file; extensions.root resolved against CWD, which is why the §9.3 semantic check fired about the project's missing directory.
The security shape matters for this key in particular: a user-level config silently supplies an ACL policy to every project that user runs, while the project's own ./acl/ is ignored — the inverse of what a default-deny, explicitly-granted ACL system is for.
The wider ecosystem lands in the same place: config-file-relative is the recommended default (mypy#7967, psalm#1524, mkdocs#543), with global/user-level config treated as a carve-out rather than a root for defaults.
Scope — what this issue is not
Not the apcore-cli sandbox.--sandbox under a relative extensions root was the symptom that led here, and it is already fixed, in code (apcore-cli-python .../security/sandbox.py:178, apcore-cli-typescript src/security/sandbox.ts:127, apcore-cli-rust src/security/sandbox.rs:258 and :268-280, with a dedicated inherited-env test at :533) and in contract (apcore-cli docs/features/security.md:140-145, cross-SDK invariant 6, plus the SANDBOX_PATH_TYPED_VARS set). No propagation clause is proposed for apcore: apcore has no spawn boundary and core conformance fixtures cannot exercise one.
Not rfc-config-include.md. Its Status is Proposed (:9, "No SDK implementation exists yet") — design precedent, not an existing rule. Adopting B′ settles that RFC's open question Welcome to apcore Discussions! #1 as a side effect, since B′ gives the whole system one base.
Step C — declare the path-typed key set. LANDED (spec v1.34.0 §9.2.1).
The set is closed and declared by "x-apcore-path": true in schemas/apcore-config.schema.json, with a public accessor required of every SDK. Additive; no resolution behaviour defined. Both options below needed it in order to state what they apply to.
Then one of:
Option A — CWD at consumption, every tier
Closest to schema.root and to extensions.root today. Must additionally define when resolution happens: a chdir() after config load otherwise leaves two consumers of one Config looking at different directories. Changes acl.root (removes D-64's file-relative behaviour entirely, including for tiers 1-5 where it is the better answer). MINOR — it adds a dependable cross-SDK contract even where no implementation changes.
Option B′ — one project root, resolved once (recommended)
project_root =
directory of the config file when it came from §9.14 tier 1-5
(explicitly pointed at, or project-local)
CWD when it came from tier 6-7 (user-level),
or when no config file was found
Every relative path-typed value resolves against project_root — file-declared, env-sourced, API-supplied, and defaults alike.
Three properties, each of which was a defect in the original Option B:
User-level configs resolve against CWD, which is what a per-user default means. Tiers 6-7 stop being a trap.
Defaults have an unambiguous home../schemas resolves against project_root like everything else — the question Option B left open.
No per-key origin tracking is required. One base per Config means no implementation needs to record which tier produced a value. This removes the largest implementation cost the original proposal carried across three SDKs. APCORE_ACL_ROOT=./x resolving against the project root is also the better reading of intent: an operator setting an env var for a project run means "relative to this project".
Migration cost, by tier:
Tier
Change
2-5 (most projects)
None.project_root == CWD already.
6-7 (user-level)
acl.root moves from the user-level directory to CWD — a bug fix, not a regression.
1, config outside CWD
The one genuine break.
Correction (second revision): this issue previously implied that last row was rare. That was not measured, and the measurement does not support it. Implementing the deprecation warning in apcore-python made the shape countable for the first time: the SDK's own test suite fires it across a large number of sites, every one of them the same pattern — write an apcore.yaml into a temporary directory, load it without changing into that directory. A test suite is not a deployment population, so this does not establish how many users are affected; what it does establish is that "load a configuration from a path outside CWD" is an ordinary thing to write, not an exotic one. The row stays "the one genuine break", but the claim that its blast radius is small is withdrawn.
Nor can the usual proxy be used. One would normally argue from silence — nobody has reported confusion, so the population must be small. That argument is unavailable here: apcore-python's warning was, until v1.36.0, suppressed by CPython's warnings de-duplication for every caller after the first at a given source line. An application with its own load_config() wrapper therefore warned once, ever, no matter how many configurations it loaded, because the registry key is the wrapper's line and not the caller's. The signal that would have told us the size of this population was itself broken.
Still a behaviour change to deployed configurations → MAJOR, and §13.2 ("Keep at least 2 minor versions for deprecation period", :8730) sets the floor. The deprecation warning stays narrow — emitted only when project_root != CWDand a relative path-typed value is present — but "narrow" should no longer be read as "almost nobody".
Recommendation: B′, on the deprecate-then-2.0 route. A is defensible if the migration is judged not worth it, but it standardises the footgun and discards D-64's correct behaviour in tiers 1-5 along with its incorrect behaviour in 6-7.
Conformance fixtures
Model on observable consumers, not on a four-key enumeration:
schema.root (all three SDKs) and extensions.root.
Extend the existing acl_root_discovery fixture with cases pinning the env-override origin rule and the user-level tier, which is where the current behaviour is wrong.
B′ needs one case per §9.14 tier, because the tier is what selects the base.
Approval
Per CLAUDE.md and GOVERNANCE.md § Decision Making, a docs/spec/protocol-spec.md change needs a linked issue (this one) and maintainer approval — 2 maintainers, or all of them when fewer than 3 exist. MAINTAINERS.md lists one, so one approval satisfies it. Spec version currently 1.34.0.
Status (second revision)
Landed:
Step C — §9.2.1, the closed path-typed key set, declared by "x-apcore-path": true in the canonical schemas, with a public accessor in all three SDKs (spec v1.34.0). §9.2.1 later gained requirement 5 (an empty string is not a path, v1.36.0), which is a direct consequence: the set is what makes the guard expressible in one place.
B′'s deprecation phase — §9.2.2 defines the project root, states the v2.0 target semantics, and requires the narrow warning. All three SDKs expose project_root and emit it. No resolution behaviour has changed; acl.root still resolves against the config file's directory and schema.root against CWD, exactly as before.
Open: the v2.0 switch itself, after the §13.2 window.
Two findings from implementing the deprecation phase
1. The warning's cadence was unspecified, and the three SDKs promptly invented three. §9.2.2 as first published required the warning and its two narrowing conditions but said nothing about how often it fires. Python de-duplicated through the warnings filter, TypeScript held a module-global once-flag, Rust warned per load — the same "specification is silent, so each implementation answers differently" pattern this issue exists to close, reproduced inside the change that closes it. Fixed in v1.36.0: once per configuration load, never once per process.
The reasoning was not theoretical. Removing TypeScript's flag turned 8 tests red, and the coupling it exposed was the hazard the clause names: three suites called a reset hook in beforeEach/afterEach, and two conformance drivers reset the flag per case — in the driver author's words, "precisely so the second warning-expecting case wouldn't observe the first's suppression". The global was already corrupting test isolation; it had simply been papered over with reset hooks.
2. Adding a canonical default for bindings.dir slightly widened the warning population.bindings.dir is path-typed, and v1.36.0 gave it the canonical default ./bindings it had always been missing. A configuration that leaves the key undeclared therefore now carries a relative path-typed value where it previously carried none, so a project whose other roots are all absolute, with a configuration file outside CWD, will newly see the deprecation warning. Spelling bindings.dir absolutely silences it. This is a warning-only effect — no resolution behaviour changes — but it is a real consequence of the §9.2.1 work and is recorded here rather than discovered later.
Summary
Two sibling path keys, identical relative values, identical override syntax, different bases — in shipped code today:
config-dir/apcore.yaml, process CWD isrun-dir/APCORE_ACL_ROOT=./xACL.discoverresolves againstconfig.source_path's directoryconfig-dir/xAPCORE_SCHEMA_ROOT=./xSchemaLoaderresolves against process CWDrun-dir/xEvidence:
apcore-python src/apcore/acl.py:1479-1489—discover()reads the merged value viaconfig.get("acl.root", …), then resolves a relative result againstPath(config.source_path).resolve().parentwhen the config has a source path, else CWD. The merged value carries no record of which tier produced it, so an env-sourced./xis resolved as though it had been written in the file.apcore-python src/apcore/schema/loader.py:503—Path(config.get("schema.root", …)).resolve(), i.e. CWD-relative, unconditionally.apcore-typescript src/schema/loader.ts:45(resolve(...)) andapcore-rust src/schema/loader.rs:64(PathBuf::from, joined later) match.Neither behaviour violates the spec, because the spec does not have an opinion.
Root cause
§9.1.1 gives all four path-valued keys a relative default —
extensions.root: "./extensions",schema.root: "./schemas",acl.root: "./acl",bindings.dir: "./bindings"— and neither §9.1.1, §9.2, nor §9.3 (which asks only for "a valid directory path") states what a relative value is relative to.One base has been written down, in a feature contract rather than the protocol spec:
docs/features/acl-system.md:356(decision D-64) specifies config-file-relative-else-CWD foracl.root. That is the ruleACL.discoverimplements, and it is whyacl.rootis the key that diverges from its three siblings.Why Option B was replaced
The original proposal — "file-declared values resolve against the config file's directory, env/API values against CWD" — does not survive contact with §9.14's discovery order:
That order splits the question into three cases, only one of which is contested:
extensions.root: ./extensionsin~/.config/apcore/config.yamlwould resolve to~/.config/apcore/extensions. A user-level config's relative paths are per-project by intent; they cannot mean "next to the config file".This is not hypothetical — it is live today in
acl.root, the one key already implementing B-like behaviour. Reproduced with a user-level config and CWD set to a project directory:Read the last two lines together: one config document, one load, two bases.
acl.rootresolved next to the config file;extensions.rootresolved against CWD, which is why the §9.3 semantic check fired about the project's missing directory.The security shape matters for this key in particular: a user-level config silently supplies an ACL policy to every project that user runs, while the project's own
./acl/is ignored — the inverse of what a default-deny, explicitly-granted ACL system is for.The wider ecosystem lands in the same place: config-file-relative is the recommended default (mypy#7967, psalm#1524, mkdocs#543), with global/user-level config treated as a carve-out rather than a root for defaults.
Scope — what this issue is not
apcore-clisandbox.--sandboxunder a relative extensions root was the symptom that led here, and it is already fixed, in code (apcore-cli-python .../security/sandbox.py:178,apcore-cli-typescript src/security/sandbox.ts:127,apcore-cli-rust src/security/sandbox.rs:258and:268-280, with a dedicated inherited-env test at:533) and in contract (apcore-cli docs/features/security.md:140-145, cross-SDK invariant 6, plus theSANDBOX_PATH_TYPED_VARSset). No propagation clause is proposed for apcore: apcore has no spawn boundary and core conformance fixtures cannot exercise one.rfc-config-include.md. Its Status is Proposed (:9, "No SDK implementation exists yet") — design precedent, not an existing rule. Adopting B′ settles that RFC's open question Welcome to apcore Discussions! #1 as a side effect, since B′ gives the whole system one base.bindings.diror Rust'sextensions.root. Registered config keys that reach no consumer — separate defect, Two registered config keys reach no consumer:bindings.dir's scan MUST is unimplemented in all three SDKs, and Rust never readsextensions.root#114.Verified status quo
Configbyacl.rootACL.discover(all 3)schema.rootSchemaLoader(all 3)extensions.rootapcore-python registry/registry.py:463,apcore-typescript registry/registry.ts:306; Rust as of apcore-rust#39bindings.dirProposal
Step C — declare the path-typed key set. LANDED (spec v1.34.0 §9.2.1).
The set is closed and declared by
"x-apcore-path": trueinschemas/apcore-config.schema.json, with a public accessor required of every SDK. Additive; no resolution behaviour defined. Both options below needed it in order to state what they apply to.Then one of:
Option A — CWD at consumption, every tier
Closest to
schema.rootand toextensions.roottoday. Must additionally define when resolution happens: achdir()after config load otherwise leaves two consumers of oneConfiglooking at different directories. Changesacl.root(removes D-64's file-relative behaviour entirely, including for tiers 1-5 where it is the better answer). MINOR — it adds a dependable cross-SDK contract even where no implementation changes.Option B′ — one project root, resolved once (recommended)
Every relative path-typed value resolves against
project_root— file-declared, env-sourced, API-supplied, and defaults alike.Three properties, each of which was a defect in the original Option B:
./schemasresolves againstproject_rootlike everything else — the question Option B left open.Configmeans no implementation needs to record which tier produced a value. This removes the largest implementation cost the original proposal carried across three SDKs.APCORE_ACL_ROOT=./xresolving against the project root is also the better reading of intent: an operator setting an env var for a project run means "relative to this project".Migration cost, by tier:
project_root == CWDalready.acl.rootmoves from the user-level directory to CWD — a bug fix, not a regression.Correction (second revision): this issue previously implied that last row was rare. That was not measured, and the measurement does not support it. Implementing the deprecation warning in apcore-python made the shape countable for the first time: the SDK's own test suite fires it across a large number of sites, every one of them the same pattern — write an
apcore.yamlinto a temporary directory, load it without changing into that directory. A test suite is not a deployment population, so this does not establish how many users are affected; what it does establish is that "load a configuration from a path outside CWD" is an ordinary thing to write, not an exotic one. The row stays "the one genuine break", but the claim that its blast radius is small is withdrawn.Nor can the usual proxy be used. One would normally argue from silence — nobody has reported confusion, so the population must be small. That argument is unavailable here: apcore-python's warning was, until v1.36.0, suppressed by CPython's
warningsde-duplication for every caller after the first at a given source line. An application with its ownload_config()wrapper therefore warned once, ever, no matter how many configurations it loaded, because the registry key is the wrapper's line and not the caller's. The signal that would have told us the size of this population was itself broken.Still a behaviour change to deployed configurations → MAJOR, and §13.2 ("Keep at least 2 minor versions for deprecation period",
:8730) sets the floor. The deprecation warning stays narrow — emitted only whenproject_root != CWDand a relative path-typed value is present — but "narrow" should no longer be read as "almost nobody".Recommendation: B′, on the deprecate-then-2.0 route. A is defensible if the migration is judged not worth it, but it standardises the footgun and discards D-64's correct behaviour in tiers 1-5 along with its incorrect behaviour in 6-7.
Conformance fixtures
Model on observable consumers, not on a four-key enumeration:
schema.root(all three SDKs) andextensions.root.acl_root_discoveryfixture with cases pinning the env-override origin rule and the user-level tier, which is where the current behaviour is wrong.bindings.diruntil its consumer exists (Two registered config keys reach no consumer:bindings.dir's scan MUST is unimplemented in all three SDKs, and Rust never readsextensions.root#114).Approval
Per
CLAUDE.mdandGOVERNANCE.md§ Decision Making, adocs/spec/protocol-spec.mdchange needs a linked issue (this one) and maintainer approval — 2 maintainers, or all of them when fewer than 3 exist.MAINTAINERS.mdlists one, so one approval satisfies it. Spec version currently 1.34.0.Status (second revision)
Landed:
"x-apcore-path": truein the canonical schemas, with a public accessor in all three SDKs (spec v1.34.0). §9.2.1 later gained requirement 5 (an empty string is not a path, v1.36.0), which is a direct consequence: the set is what makes the guard expressible in one place.project_rootand emit it. No resolution behaviour has changed;acl.rootstill resolves against the config file's directory andschema.rootagainst CWD, exactly as before.Open: the v2.0 switch itself, after the §13.2 window.
Two findings from implementing the deprecation phase
1. The warning's cadence was unspecified, and the three SDKs promptly invented three. §9.2.2 as first published required the warning and its two narrowing conditions but said nothing about how often it fires. Python de-duplicated through the
warningsfilter, TypeScript held a module-global once-flag, Rust warned per load — the same "specification is silent, so each implementation answers differently" pattern this issue exists to close, reproduced inside the change that closes it. Fixed in v1.36.0: once per configuration load, never once per process.The reasoning was not theoretical. Removing TypeScript's flag turned 8 tests red, and the coupling it exposed was the hazard the clause names: three suites called a reset hook in
beforeEach/afterEach, and two conformance drivers reset the flag per case — in the driver author's words, "precisely so the second warning-expecting case wouldn't observe the first's suppression". The global was already corrupting test isolation; it had simply been papered over with reset hooks.2. Adding a canonical default for
bindings.dirslightly widened the warning population.bindings.diris path-typed, and v1.36.0 gave it the canonical default./bindingsit had always been missing. A configuration that leaves the key undeclared therefore now carries a relative path-typed value where it previously carried none, so a project whose other roots are all absolute, with a configuration file outside CWD, will newly see the deprecation warning. Spellingbindings.dirabsolutely silences it. This is a warning-only effect — no resolution behaviour changes — but it is a real consequence of the §9.2.1 work and is recorded here rather than discovered later.