Skip to content

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

Description

@tercel

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-1489discover() 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:503Path(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:

1.   $APCORE_CONFIG_FILE                 — arbitrary path
2-5. ./project.yaml|.yml|apcore.yaml|.yml — CWD-relative
6.   ~/.config/apcore/config.yaml         — user-level (XDG; ~/Library/… on macOS)
7.   ~/.apcore/config.yaml                — legacy user-level

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.
  • Not bindings.dir or Rust's extensions.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 reads extensions.root #114.

Verified status quo

Key Read from Config by Base in effect today
acl.root ACL.discover (all 3) Config-file dir, else CWD — including for env-sourced values, and including the user-level tier
schema.root SchemaLoader (all 3) CWD
extensions.root apcore-python registry/registry.py:463, apcore-typescript registry/registry.ts:306; Rust as of apcore-rust#39 CWD
bindings.dir nothing, in any SDK n/a — #114

Proposal

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:

  1. User-level configs resolve against CWD, which is what a per-user default means. Tiers 6-7 stop being a trap.
  2. Defaults have an unambiguous home. ./schemas resolves against project_root like everything else — the question Option B left open.
  3. 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 != CWD and 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:

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions