Skip to content

fix(agent-binding,grants): surface the seat confinement refusal on the grant read (TASK-063) - #1766

Merged
lilyshen0722 merged 4 commits into
mainfrom
kai/task063-grant-read-surfacing
Sep 19, 2026
Merged

lilyshen0722 merged 4 commits into
mainfrom
kai/task063-grant-read-surfacing

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

What this is

TASK-063's second half: the grant read. #1765 shipped the server-side refusal — the projection
stops injecting the broker into a seat no host can confine, and says so on the daemon's /assigned
row. That field is internal to the daemon: no human sees it. So the person who minted a grant
still had no way to learn that the broker was being withheld from the seat it names.

GET /api/grants/:grantId is where that person looks, and it now reports it:

"grantBrokerRefusal": { "code": "grant_broker_unconfined", "decidedBy": "server",
                        "reason": "adapter_cannot_confine", "detail": "" },
"grantBrokerRefusalScope": "seat"

The scope is the honesty mechanism, and it is four-valued (Vera 69881, then 69890). It names
what was judged, never which layer decided — the layer is already inside the refusal as decidedBy:

scope meaning grantBrokerRefusal
seat the seat's environment was resolved and judged null = checked, not refused
not_installed the seat resolved to a governing owner, but that owner holds no installation of this identity + instance, so the projection carries no row for it null = nothing was delivered, so nothing was judged
unbound the seat could not be resolved to a governing installation — no machine binding, or no identity to bind null = not judged
not_evaluated the grant names a pod, not a seat: the refusal is a property of each seat that redeems it null = there is no single verdict

Two holds, both about the same failure mode — a null that means two things. At f072aa73 the scope
was the constant 'server' for a pod-target grant ("checked, not refused" on the one surface the
granter looks at, while /assigned withholds the broker per seat). At aa36a497 a bound seat whose
owner holds no row for it
returned seat + null, which reads as "judged, the broker reaches it";
in fact the daemon is handed nothing for that seat. A field whose value cannot vary carries no
information about coverage, and the fourth value is what makes "not delivered" sayable.

Why it is not a one-file change

The read cannot resolve a seat's environment. grep -arn AgentInstallation backend/routes/grants.ts backend/services/roomGrantService.ts returns nothing, and the obvious
shortcut is a trap: RoomGrant.installationId is the GitHub App connection's installation id
(grants.ts:159-160, written by roomGrantService.ts:263), not an AgentInstallation id — so the
grant cannot even name the seat's installation from its own row.

The projection that can answer this lived inline inside the /assigned handler
(agentBinding.ts:417-440). Writing a second resolver in grants.ts is precisely the "second
definition of the predicate" the TASK-063 ruling forbids, so the first commit extracts it:

new backend/services/seatEnvironmentProjection.ts — the placeholder-only env/header allow-list, projectEnvironment, and projectSeatEnvironments({ installedBy })
agentBinding.ts calls the extracted function; the inline map, the AgentInstallation require and the projection helpers are gone
grants.ts resolves a verdict for the grants it is already projecting, via the same function

The extraction is behaviour-preserving: no ordering is imposed (first declaration still wins for a
duplicated identity), the daemon list still returns early when the owner has no active installations,
and the placeholder allow-list is moved, not rewritten.

One thing the move fixes for free: the daemon list built its identity keys with a raw NUL byte
inside a template literal while reading them back with the \0 escape. Same string at runtime, but it
made agentBinding.ts a binary file — grep said "Binary file matches" and sed rendered the
separator as a space. The extracted module uses the escape form and the key has one definition
(seatEnvironmentKey), which both sides of every lookup now call. That is visible in the mutation
table below: the separator mutation used to break seven tests across two files, because the two sides
could drift; it now breaks exactly the key's own assertion, because they cannot.

Scope: one resolution rule, no ordering guess

/assigned builds its work list from the machine owner's active installations. The read resolves
the same way — the seat's botMetadata.machineIdMachine.ownerUserId → that owner's projection —
which is what makes the two surfaces agree by construction rather than by coincidence when two
users have installed the same agent identity with different declarations. The route test proves it in
both directions by re-homing the seat's machine between two owners whose declarations disagree.

The identity-wide fallback is gone, not relabelled. For an unbound seat it returned whichever
installation Mongo happened to order first — a guess presented as a verdict, on a surface whose job
is to say whether a security control is in force. An unbound seat now reports unbound and its
refusal is null because nothing was judged. Three cases are deliberately kept distinct:

  • bound, owner has an installation of this seat → judged; seat.
  • bound, owner has no such installationnot_installed: the scope resolved (so this is not
    unbound — a daemon is polling that machine), but the projection holds no row for the seat, so
    there is no environment to judge and the broker reaches nobody. This is the arm Vera 69890 added,
    and the test that asserted seat here was inverted deliberately with a comment saying so.
    Both shapes of it are covered: an owner with no installations at all, and an owner whose active
    installations are all for other identities — what makes the arm true is that THIS seat has no
    row, not that the owner has none.
  • unboundunbound, and nothing is consulted.

Tests

Backend, whole suite on this head: 410 of 412 suites passed, 3676 tests passed, 22 failed across 2
suites
— and both failing suites are the same environment failure, named rather than waved at:
mongodb-memory-serverInstance failed to start within 10000ms, which takes down a whole suite at
setup. Re-run in isolation with jest --onlyFailures on this head:
leakMatrix.agentRuntime.test.js PASS, agentBinding.phase2.test.js PASS. Neither is touched
by this PR (the changed files are grants.ts, agentBinding.ts, the extracted service and the two
test files); the failure mode is instance startup under a busy box, not an assertion. lint:ts 0
errors (1207 pre-existing warnings). The four suites this change affects, run on their own: 74
passed, 0 failed
.

  • New __tests__/unit/services/seatEnvironmentProjection.test.js (5) — the composite key, owner
    scope, first-declaration-wins, that a scoped call cannot leak another owner's seat, the
    instanceId normalisation (the schema lowercases agentName but not instanceId, so a raw
    template string would miss a seat stored as Quill), and the placeholder-only env allow-list.
  • __tests__/unit/routes/grants.read.test.js (9 new) — a pi seat reads as refused with the layer
    named; a confining declaration and the 29-of-30 plain shape read as no refusal; mode: 'none'
    reads as refused; the machine-owner scope, in both directions; an unbound seat reads unbound
    with a bound control at an identical fixture, so the assertion is about the binding rather than
    about a missing installation; a bound seat with no installation under its owner reads
    not_installed with an installed control at an identical fixture, plus the owner-installed-for-
    other-seats shape of the same arm; the pod list carries the same refusal; a pod-addressed grant
    reads not_evaluated with a seat-grant control.
  • The read's field list is asserted exactly by the pre-existing tests, so
    grantBrokerRefusal/grantBrokerRefusalScope are pinned there rather than added silently.

Eleven independent mutations, each reddening its own tests (serialized; control 74/74 green):

mutation result
the key's NUL separator becomes a space 1 red (the key's own test — both sides call the one helper now)
identity parts stop being lowercased 2 red (the key test and the Quill lookup)
the read ignores the computed refusal 5 red
the scope is hardcoded back to a constant 2 red (unbound + pod target)
a pod target reports the seat scope 1 red (the pod test alone)
the resolution drops the owner scope (unscoped scan) 2 red (the scope test and the owner-has-not-installed case)
an unbound seat proceeds to be judged 1 red (the ordering-guess test alone)
a resolved-but-absent entry reports unbound 1 red before this arm existed (the owner-has-not-installed test alone)
not_installed relabelled seat (the pre-69890 reading) 2 red (both new arm tests)
not_installed relabelled unbound (the other wrong value) 2 red (both new arm tests)
the entry ternary inverted — a delivered seat reported not_installed 9 red (every judged-seat assertion)

Honest limit

A refusal made by the DAEMON is still invisible here. The heartbeat keeps only four fields after
normalizeAgentStates and Machine.ts declares no strict option, so Mongoose drops undeclared
paths and the server never learns that a daemon refused. The three scope values are therefore a
statement about the server's verdict only — none of them means "the broker reached nobody", and a
daemon that refuses after deriving its own environment is reported by none of the four values. A
seat whose declaration the server cannot judge is decided at the daemon, exactly the boundary #1765
documented.

Still open on TASK-063, deliberately not in this PR: the generic derive-time refusal in the cli's
environmentFor (needs a cli slot, behind the 0.1.51→0.1.56 ladder).

Merge order

Backend-only, no version bump, and it does not touch any cli file — so it does not queue behind the
cli ladder and no rung of it orders this. Cut from 373ac260 (#1765) and rebased onto d5afd20d
(#1753); no open PR touches any of the five files it changes.

Not in this PR: the server-side case hardening

Vera's route out of the case-insensitivity class — mount the grants router with caseSensitive: true
so an uppercased broker path 404s for every adapter, present and future — is deliberately not
here, and Vera has agreed that recording it on TASK-063 with a trigger is the right place. The daemon
refusal already covers every spelling for the only confinementless adapter, so what that change buys
is a third enforcement point against an adapter that does not exist yet; that is a change to an
existing route's matching contract and belongs in its own PR, not appended to one under gate.

@samxu01
samxu01 force-pushed the kai/task063-grant-read-surfacing branch from f072aa7 to 527cd7a Compare September 19, 2026 11:46
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
…t was judged (TASK-063)

Vera 69881, holding #1766: a pod-target grant returned `grantBrokerRefusal: null`
with scope 'server', which reads as 'checked, not refused' while `/assigned`
withholds the broker per seat. The scope was a constant, so the field carried no
information about coverage at all.

`grantBrokerRefusalScope` is now three-valued and names WHAT was judged, never
which layer decided (the layer is inside the refusal as `decidedBy`):

  seat           the seat's environment was resolved and judged; null means
                 checked and not refused.
  unbound        the seat could not be resolved to a governing installation —
                 no machine binding, or no identity to bind. NOTHING was judged.
  not_evaluated  the grant names a pod, not a seat. The refusal is a property of
                 each seat that redeems it, so there is no single verdict.

The identity-wide fallback is gone, not just relabelled: for an unbound seat it
returned whichever installation Mongo happened to order first, a guess dressed
as a verdict. An unbound seat now reports that it was not judged. A seat whose
machine IS bound resolves through the owner as before, and a bound seat that
owner has not installed is a real verdict — the daemon is handed no row for it
at all — which is why that case stays 'seat' rather than collapsing into
'unbound'.

`projectSeatEnvironments` loses the now-dead identity filters with it, so the
only scope either caller can ask for is the owner's, and the empty-identity-scan
hole those filters created is gone by construction rather than by a guard.
lilyshen0722 pushed a commit that referenced this pull request Sep 19, 2026
…t was judged (TASK-063)

Vera 69881, holding #1766: a pod-target grant returned `grantBrokerRefusal: null`
with scope 'server', which reads as 'checked, not refused' while `/assigned`
withholds the broker per seat. The scope was a constant, so the field carried no
information about coverage at all.

`grantBrokerRefusalScope` is now three-valued and names WHAT was judged, never
which layer decided (the layer is inside the refusal as `decidedBy`):

  seat           the seat's environment was resolved and judged; null means
                 checked and not refused.
  unbound        the seat could not be resolved to a governing installation —
                 no machine binding, or no identity to bind. NOTHING was judged.
  not_evaluated  the grant names a pod, not a seat. The refusal is a property of
                 each seat that redeems it, so there is no single verdict.

The identity-wide fallback is gone, not just relabelled: for an unbound seat it
returned whichever installation Mongo happened to order first, a guess dressed
as a verdict. An unbound seat now reports that it was not judged. A seat whose
machine IS bound resolves through the owner as before, and a bound seat that
owner has not installed is a real verdict — the daemon is handed no row for it
at all — which is why that case stays 'seat' rather than collapsing into
'unbound'.

`projectSeatEnvironments` loses the now-dead identity filters with it, so the
only scope either caller can ask for is the owner's, and the empty-identity-scan
hole those filters created is gone by construction rather than by a guard.
@lilyshen0722
lilyshen0722 force-pushed the kai/task063-grant-read-surfacing branch from 6cb25b8 to c29a5a4 Compare September 19, 2026 12:19
Lily Shen and others added 4 commits September 19, 2026 05:30
…he grant read can use it (TASK-063)

The grant read has to tell whoever minted a grant that the broker is being
withheld from the seat, and it can only do that by resolving the seat's
environment the way the daemon projection does. That projection lived inline
in the /assigned handler, so the read would have had to restate it — the
second definition of the predicate the TASK-063 ruling forbids.

Move the identity -> environment projection (and its placeholder allow-list)
into services/seatEnvironmentProjection.ts and call it from both. The
extraction is behaviour-preserving: no ordering is imposed, first declaration
still wins for a duplicated identity, and the daemon list keeps returning
early when the owner has no active installations.

Also surfaces the refusal on the grant read and the pod grant list:
grantBrokerRefusal beside grantBrokerRefusalScope: 'server', because a
DAEMON-side refusal is not reported here yet (the heartbeat carries four
fields) and the field must not read as a liveness guarantee.
…refusal it surfaces (TASK-063)

Two instruments, because they fail differently. The service suite pins what a
refactor can break with no route turning red: the NUL composite key the daemon
list joins on, the instanceId normalisation (the schema lowercases agentName
but not instanceId), first-declaration-wins, the owner scope, an empty identity
scope returning nothing rather than the whole collection, and the
placeholder-only env allow-list that keeps the daemon-token boundary.

The route suite asserts the read AGREES with the daemon projection: a pi seat
reads as refused with the layer named, a confining declaration and the
29-of-30 plain shape read as no refusal, and — the load-bearing one — the
resolution scope is the seat's machine OWNER, proven in both directions by
re-homing the seat's machine between two owners whose declarations disagree.

The read's field list gains grantBrokerRefusal and grantBrokerRefusalScope,
which the existing field-list assertions pin.
…t was judged (TASK-063)

Vera 69881, holding #1766: a pod-target grant returned `grantBrokerRefusal: null`
with scope 'server', which reads as 'checked, not refused' while `/assigned`
withholds the broker per seat. The scope was a constant, so the field carried no
information about coverage at all.

`grantBrokerRefusalScope` is now three-valued and names WHAT was judged, never
which layer decided (the layer is inside the refusal as `decidedBy`):

  seat           the seat's environment was resolved and judged; null means
                 checked and not refused.
  unbound        the seat could not be resolved to a governing installation —
                 no machine binding, or no identity to bind. NOTHING was judged.
  not_evaluated  the grant names a pod, not a seat. The refusal is a property of
                 each seat that redeems it, so there is no single verdict.

The identity-wide fallback is gone, not just relabelled: for an unbound seat it
returned whichever installation Mongo happened to order first, a guess dressed
as a verdict. An unbound seat now reports that it was not judged. A seat whose
machine IS bound resolves through the owner as before, and a bound seat that
owner has not installed is a real verdict — the daemon is handed no row for it
at all — which is why that case stays 'seat' rather than collapsing into
'unbound'.

`projectSeatEnvironments` loses the now-dead identity filters with it, so the
only scope either caller can ask for is the owner's, and the empty-identity-scan
hole those filters created is gone by construction rather than by a guard.
…d (TASK-063)

Vera 69890: a bound seat whose owner holds no installation of that identity
and instance read as `seat` + null, which says "judged, the broker reaches
it". The owner's projection carries no row for the seat, so the daemon is
handed nothing and nothing was judged. Adds a fourth coverage value,
`not_installed`, and inverts the test that pinned the old reading.
@lilyshen0722
lilyshen0722 force-pushed the kai/task063-grant-read-surfacing branch from c29a5a4 to 5eaafcf Compare September 19, 2026 12:30
@lilyshen0722
lilyshen0722 merged commit ce3a8b6 into main Sep 19, 2026
14 checks passed
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…t installation that declares either half

TASK-019 ruling, option i (wren 69800). The pair is taken from the OLDEST
active installation (`_id` ascending) that declares EITHER half; a half that
installation is silent about stays empty rather than being filled from a
sibling row; `podIds` stays a union across the identity's installations.

The site is not the parked patch's, and that is the point. The parked change
(branch `kai/task019-i-pair-atomic`, 59d9938 + 7a354d95) patched the /assigned
handler in `routes/agentBinding.ts`. TASK-063 (#1765, #1766) then refactored
that handler to call `projectSeatEnvironments`, shared with the grant read, so
the handler no longer holds the projection — applying the parked patch to
82eabcd conflicted on exactly that hunk. The behaviour therefore lands once, in
`services/seatEnvironmentProjection.ts`, where both the daemon list and the
grant read resolve it.

Also `.sort({ _id: 1 })` on the query: the previous winner was Mongo's stored
order, and the doc comment here said so.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…t installation that declares either half (TASK-019 ruling i) (#1772)

* fix(agent-binding): the runtime+environment pair comes from the oldest installation that declares either half

TASK-019 ruling, option i (wren 69800). The pair is taken from the OLDEST
active installation (`_id` ascending) that declares EITHER half; a half that
installation is silent about stays empty rather than being filled from a
sibling row; `podIds` stays a union across the identity's installations.

The site is not the parked patch's, and that is the point. The parked change
(branch `kai/task019-i-pair-atomic`, 59d9938 + 7a354d95) patched the /assigned
handler in `routes/agentBinding.ts`. TASK-063 (#1765, #1766) then refactored
that handler to call `projectSeatEnvironments`, shared with the grant read, so
the handler no longer holds the projection — applying the parked patch to
82eabcd conflicted on exactly that hunk. The behaviour therefore lands once, in
`services/seatEnvironmentProjection.ts`, where both the daemon list and the
grant read resolve it.

Also `.sort({ _id: 1 })` on the query: the previous winner was Mongo's stored
order, and the doc comment here said so.

* test(agent-binding): pin the pair-atomic projection, the silent half, and the oldest-_id ordering

Witnesses for the change above, at the route tier that consumes it and at the
service tier that defines it:
- the union test now pins that BOTH halves come from the base fixture and that
  the second installation's values appear nowhere in the projected agent;
- `never fills a silent half from a sibling installation` — the case the ruling
  is about, visible only when the halves live on different rows;
- `takes the pair from the oldest _id even when that row was inserted last` —
  explicit ObjectIds, newer row inserted first, so insertion order and `_id`
  order disagree and only the find's sort can pick the older pair.

Mutations (27 passing across the two suites):
- per-field fill -> 1 failed, the silent-half test alone;
- `.sort({ _id: -1 })` -> 3 failed (pair-atomic, silent half, ordering);
- dropping `.sort()` -> 1 failed, the ordering witness alone. That clause was
  an unwitnessed tripwire in the parked fold; it now has its witness.

* fix(agent-binding): a declaration counts only when it delivers something

Vera's residual on 268370c: the predicate tested the raw key, so an OLDER row
whose environment the allow-list reduces to nothing (`environment: {}`, or only
fields the projection drops) became the source of the pair and a NEWER row's
real environment was never read. She is right that it fails toward the daemon
baseline rather than acting as a bypass, but the oldest-wins rule then picks the
row that does not describe the seat, and the seat loses the environment it
declares. "Declares" now means delivers: a half counts when it projects to
something (or, for runtime, when it is present at all).

Mutations, each run against the change (28 passing across the two suites):
- raw-truthiness predicate, i.e. the defect above -> 1 failed, the new test alone;
- marking the identity sourced on the first row regardless of whether it
  declared anything -> 1 failed, the new test alone.

Not fixed here, and named rather than left implied: `runtime: {}` still counts
as a declaration. Runtime is not passed through a projection, so the equivalent
test is a schema question (an empty object, and a nested Map that
`Object.keys` reads as empty) rather than a reduce, and guessing it here could
skip a genuine runtime fleet-wide.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant