ADR 0020: The host ceiling is a port, not a convention

  • Status: Accepted
  • Date: 2026-08-31
  • Reverts: the documentation change in 663dd94

Context

docs/security/permission-model.md on main lists twelve steps the kernel evaluates for each operation. Step 10 is:

Apply the host ceiling and any non-grant policy constraints.

There is no such call site. CapabilityAuthorizer.#decide returns allow as soon as an eligible grant matches a capability and any bounded use is consumed, and SharedOSKernel audits that decision and proceeds. The only "ceiling" in the kernel is the one a tool declares for itself, which ADR 0012 keeps deliberately separate and answers with invalid_tool_requirement.

That gap is real, and 663dd94 found it first. It resolved it in the other direction: step 10 was removed, the algorithm renumbered to eleven, and the actor definition rewritten to say the ceiling "is applied before SharedOS is asked" — through what a GrantSource returns and which namespaces a context enables — with the kernel having "no policy port of its own". Its reasoning was that nothing implements the step, that the one shipping host narrows authority exactly that way, and that a kernel-side port "would be a new trust-boundary contract needing an ADR, and no host asks for one".

This is that ADR, and it argues the opposite resolution. Taking its three grounds in turn:

"Nothing implements it." True, and the reason this decision has to be made rather than assumed. A document describing enforcement the code does not have can be fixed by deleting the claim or by implementing it. 663dd94 deleted it. The claim was load-bearing, so this ADR implements it instead.

"The one shipping host narrows authority that way." SharedEval narrows through an immutable per-run grant manifest and a per-actor enabledToolNamespaces. That is an experiment harness whose policy is fixed before the run starts and cannot depend on a request, and for that shape, upstream narrowing genuinely suffices. Generalising from a host whose policy is static to a contract that must also serve hosts whose policy is not is the error. Pulse's ceiling reads the arguments of the call — which file, which recipient — and cannot be computed when authority is loaded.

"No host asks for one." One already built it. Pulse states so in its own adapter: toolAccess.allowedTools "stays a Pulse-side ceiling applied while the tool map is built, so effective authority is Pulse ceiling ∩ SharedOS grants". The ask is not hypothetical; it is a second enforcement point that shipped because there was nowhere else to put it.

Why upstream narrowing is not the same thing

The decisive objection is not expressiveness. It is that upstream refusal is invisible and misattributed.

A host that refuses by withholding a grant produces no_matching_grant. That record says no such authority exists. The truth is that the authority exists and policy refused it. Those are different facts about a deployment, and today they are the same row — so a denial that is a deliberate product decision is indistinguishable from an owner who never granted anything. A namespace refused by policy is worse: the tool is simply absent from the catalogue and nothing is recorded at all.

Three consequences follow, and none is reachable by a host doing more work upstream:

  • The audit record is incomplete. A call the host's ceiling refused never reaches the kernel, so nothing records that a decision was made or why.
  • Denial rates cannot be computed. "No grant exists" and "a grant exists and policy overrode it" cannot be separated after the fact.
  • Conformance cannot reach it. ADR 0013 makes the matrix the case set, and a ceiling applied in host code before the kernel is called has no cell.

Not a fourth copy of an existing mechanism

SharedOS already narrows in three places, and a port that overlapped them would be worse than none:

existingwhat it narrowswhen it can be computed
AccessContext.enabledToolNamespaceswhole tool namespaces, on or offbefore any request
ToolNamespaceSettingsStorethe persisted namespace selection, after org policyat settings write
a tool's declared requiredCapabilitywhat that tool may resolve toat registration

All three are static with respect to the request: each is fully determined before the arguments of a particular call are known, and each can be applied once while a catalogue is built. A ceiling that has to read this request cannot be any of them.

Decision

The host ceiling becomes a port the kernel calls, in the position step 10 already assigns it. 663dd94's documentation change is reverted and step 10 restored.

/** Loaded once per turn, beside the grant set. */
export interface PolicySource {
  load(context: AccessContext, signal: AbortSignal): Promise<LoadedPolicy>;
}

/** The policy, and the source's own name for it: a revision, an etag, a hash. */
export interface LoadedPolicy {
  readonly policy: HostPolicy;
  readonly version: string;
}

/** Consulted per decision, over already-loaded state. Synchronous by contract. */
export interface HostCeiling {
  narrow(
    decision: AllowedDecision,
    request: AuthorizationRequest,
    context: AccessContext,
    policy: HostPolicy,
  ): HostCeilingVerdict;
}

/** The allow arm alone in; that allow, or a `host_policy_denied`, out. */
export type HostCeilingVerdict = AllowedDecision | HostPolicyDenial;

HostPolicy is opaque to SharedOS: whatever the host loaded, carried beside the resolved authority and handed back to the host's own ceiling. version is the one thing about it SharedOS reads. An opaque value has no canonical form to hash, so the source states what it loaded, and every tool.catalog.listed event in the turn records it as hostPolicyVersion beside authorityHash (ADR 0023).

Why the synchronous signature needs a second port

A synchronous ceiling can only decide against state it already holds, and the first real host's two recorded policies read a database: getFolderAccessStatus and the c2c precedent lookup are both async. Without somewhere for that read to happen, the signature would forbid the port to exactly the policies that are working today, and they would stay outside it.

They do not need a per-request query. Their keys are (owner, requester, device, folder, normalizedTool) over a bounded vocabulary — thirteen normalized tools, two access presets — so the answer set is a small table, not a lookup per argument. It can be loaded once and consulted many times.

So policy enters the way authority already does, at the same moment: one PolicySource.load per turn, asynchronous, at the turn boundary, and every decision inside the turn made against the result without reading a store. This is ADR 0009 and ADR 0010's pattern applied one level up — a turn now resolves one grant set and one policy set — and it is what the local agent already does with its policy file: read once at startup, then decide in memory.

A host with request-independent policy may ignore PolicySource entirely and close over its own state; the port exists so that a host whose policy lives in a database is not forced back outside the kernel by the signature.

PolicySource is installed as SharedOSKernelOptions.policySource, beside grantSource, because the load is a turn-boundary event and the kernel owns the turn boundary; the ceiling stays on the authorizer. The kernel loads it inside the same authority load — the two are in flight together and neither reads the other — holds the result on the turn's authority lease as ResolvedAuthority.hostPolicy, and hands it to narrow as the fourth argument on every decision in the turn, exactly as loaded: not cloned, validated, or hashed, because SharedOS does not know its shape and reads nothing from it. A kernel without a source hands the ceiling undefined, so a ceiling that closes over its own state stays a complete implementation, and the parameter admits undefined rather than promising a value whose pairing SharedOS cannot check.

It fails closed the way the grant source does. A throw is reported once, at the boundary, to SharedOSKernelOptions.onProviderError as kind: "policy", and the turn's policy is held unavailable for its whole length: every decision the ceiling would have been consulted on is refused host_policy_unavailable without narrow being called, on both paths, and before any bounded use is consumed. A kernel with no ceiling ignores it — the outage is the ceiling's, not authority's. Each authority.resolved event records hostPolicy: "loaded" | "unavailable" | "absent" beside hostCeiling, where absent means no source is installed, not that there is no policy. A result that is not a LoadedPolicy — no version, or an empty one — is the same outage, reported the same way.

The signature is synchronous, and that is the enforcement. "Deterministic and cheap" cannot be asserted in prose and then relied on. A synchronous return cannot await a network call or a model call, so the constraint is carried by the type rather than by a comment. A timeout was rejected for the same reason it would fail as a conformance signal: what it admits depends on how fast the machine is, so the same ceiling could pass on one host and fail on another.

  • It is consulted only after a grant has matched, on an allowed decision. A denial is never shown to it — the parameter is AllowedDecision, the allow arm alone — so a ceiling cannot turn one into an allow.
  • It may return only the decision it was given or a HostPolicyDenial. A returned allowed that does not carry the matchedGrantId it was handed is treated as a malfunction and fails closed, so widening is not expressible rather than merely forbidden.
  • Its denial carries host_policy_denied, and the type pins the code: a ceiling cannot author one of its own or borrow no_matching_grant, and a host outside TypeScript that returns another has it replaced. The name is the constraint that refused — host policy, the one input to this decision no grant expresses — not the component that refused, so ADR 0012's rule that "a code is what was refused; a source is who refused it" holds: which component refused is OperationRecord.source, and that is the only place it lives — a parallel copy in decision metadata would be the same fact in two shapes. The prefix keeps the pair legible: host_policy_denied is the deliberate refusal and host_policy_unavailable the broken port, one line apart in every table and separated by failClosed, not by guesswork.
  • A throw fails closed and is recorded as host_policy_unavailable, an infrastructure denial consistent with every other unavailable trusted component. The thrown error goes to CapabilityAuthorizerOptions.onProviderError and nowhere else — the same hook shape the kernel takes, declared on the authorizer because that is where the ceiling is installed and the kernel's own hook cannot reach it. A host wanting both passes one function to both.
  • A malformed return fails closed the same way. narrow is host code that may have no compiler in front of it, and the two mistakes it makes without a type error — an async narrow, and a branch that falls off the end — both produce something whose allowed is undefined. Read as a denial, the first would be recorded as a deliberate host_policy_denied and would inflate the one count this ADR exists to make trustworthy; read at all, the second throws past every call site and ends the turn with no audit event. The shape is checked before any field is read.
  • It is optional. A kernel constructed without one behaves exactly as it does today.

Who may install one, and how that is visible

Whoever constructs the kernel constructs the ceiling; there is no separate authority for installing one, because a host that can build a kernel can already choose its GrantSource. What changes is that the choice is no longer silent: the kernel records whether a ceiling is installed, so a deployment that denies everything through policy is legible in the record rather than appearing as a deployment where nobody was granted anything.

Denial-rate arithmetic

host_policy_denied is its own bucket inside policy denials — not an infrastructure denial, and not merged with no_matching_grant. Today INFRASTRUCTURE_DENIAL_REASONS is the only split the vocabulary supports, which is why a policy refusal currently has nowhere to go. The three-way shape is:

denials = infrastructure (failClosed)
        + policy { no_matching_grant, grant_exhausted, host_policy_denied, … }
escalations are neither (ADR 0011)

Both paths, and the row that proves it

The ceiling runs on authorize and on canDiscover. That is one call per tool per catalogue build plus one per invocation, which the synchronous signature is what makes affordable.

Conformance gains a row that makes the pairing checkable rather than asserted: a tool the ceiling refuses at invocation is absent from the catalogue. A ceiling consulted on only one of the two paths fails that row. The row lands with the implementation, not with this ADR: ADR 0013's strict gate covers every declared row, so a row added ahead of the code would have to be declared notImplemented with a reason.

The port alone does not close the class

Three refusal paths remain invisible or misattributed after the port lands, and the honest case for this direction includes all three.

1. A withheld grant never reaches the port. The ceiling is consulted only on an allowed decision, so a host that refuses by not returning a grant bypasses it entirely and the kernel records no_matching_grant. Closing that requires inverting the GrantSource contract: return the grants the actor holds; do not apply policy here.

That inversion is consistent with this ADR's own rejection of filtering in GrantSource. A request-dependent filter there would make AuthoritySnapshot.hash depend on the request, so the snapshot would stop identifying "the authority this turn holds". Inverting the rule protects that property rather than straining it.

It also changes what a snapshot means, and that has to be written down rather than left implicit. The snapshot then contains authority that policy will refuse, so an auditor reading it alone would overstate what the turn could do. Its meaning becomes "authority held", not "authority usable" — a shift that belongs in ADR 0010's neighbourhood.

2. Namespace enablement launders the same invisibility. enabledToolNamespaces carries two different things: the user's own settings choice, and organization policy. Split them by intent. The user's choice stays where it is; policy-driven namespace denial moves into the ceiling, where it produces a recorded host_policy_denied instead of a silent absence. Without the split, the port covers one field while the same refusal keeps flowing through another.

3. The pre-kernel host gate stays outside either way. The determinism rule excludes a model-based sanitizer by design, so a host that runs one still runs it before the kernel is asked. Choosing the port does not remove the obligation that comes with that: such a host emits its verdict to the same AuditSink with the same outcome vocabulary. The port is added on top of that contract, not in place of it.

Net effect. With only the port, a deployment gets one covered path, one still misattributed, one still silent — and a host_policy_denied count that reads as complete when it is not. All three follow-ons have to land for the class to actually close.

Consequences

  • The permission model's step 10 becomes true of the code. Until now it described an intention, and 663dd94 was right that the two disagreed.
  • A deployment can answer "how often does our own policy override a grant we issued", which is the question that tells an operator their grants are wider than their policy.
  • AuditOutcome gains no new value: a ceiling denial is a denial. What is new is that it is recorded at all, and distinguishable by reason code.
  • Hosts that apply a ceiling today keep the same logic and move the call site. Pulse's toolAccess.allowedTools intersection is the first candidate, and it stops being invisible without becoming a grant.
  • Judgment layers that are not expressible as grants — a relationship model, a content-sensitivity check, an org-wide freeze — get a home that cannot widen authority, instead of wrapping the kernel where they can do anything.
  • SharedEval is unaffected. A host that narrows entirely upstream installs no ceiling and behaves exactly as before; its ADR 0002 prohibition on a second authorizer in the host is untouched, because a ceiling is not a second authorizer — it cannot allow anything.

Rejected alternatives

Delete the claim instead of implementing it (663dd94). Rejected on the evidence above: the enforcement it removed is the only one that can record a policy refusal, and its replacement — refuse by not issuing, not returning, or not enabling — is precisely the invisible path. It is the right change if SharedOS only ever serves hosts whose policy is fixed before a run; it is the wrong one for a host whose policy reads the request.

Leave it a convention. Rejected. The convention already produced a second enforcement point in the first product host, with no audit and no test.

Filter the grants in GrantSource instead. Rejected: a ceiling that depends on the request cannot be applied when authority is loaded, and it would make the authority snapshot depend on the request, breaking the one-snapshot-per-turn property ADR 0010 relies on.

Reuse ToolNamespaceSettingsStore. Rejected: it narrows a persisted namespace selection at settings-write time and cannot read a call's arguments. Widening it into a per-request hook would give one interface two unrelated jobs and two different lifetimes.

An async signature with a timeout. Rejected. It admits a network or model call and then bounds it by wall time, so what passes depends on machine speed — which cannot be a conformance signal, and which makes the ceiling's determinism a property of the deployment rather than of the contract.

Let the ceiling return an escalation. Rejected here and answered in ADR 0019. A ceiling says no; whether a no is worth asking a human about is the host's decision, made on the denial it receives, using the capability that denial describes.