fix(agent-binding,grants): surface the seat confinement refusal on the grant read (TASK-063) - #1766
Merged
Merged
Conversation
samxu01
force-pushed
the
kai/task063-grant-read-surfacing
branch
from
September 19, 2026 11:46
f072aa7 to
527cd7a
Compare
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
force-pushed
the
kai/task063-grant-read-surfacing
branch
from
September 19, 2026 12:19
6cb25b8 to
c29a5a4
Compare
…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
force-pushed
the
kai/task063-grant-read-surfacing
branch
from
September 19, 2026 12:30
c29a5a4 to
5eaafcf
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/assignedrow. 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/:grantIdis where that person looks, and it now reports it: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:grantBrokerRefusalseatnull= checked, not refusednot_installednull= nothing was delivered, so nothing was judgedunboundnull= not judgednot_evaluatednull= there is no single verdictTwo holds, both about the same failure mode — a
nullthat means two things. Atf072aa73the scopewas the constant
'server'for a pod-target grant ("checked, not refused" on the one surface thegranter looks at, while
/assignedwithholds the broker per seat). Ataa36a497a bound seat whoseowner 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.tsreturns nothing, and the obviousshortcut is a trap:
RoomGrant.installationIdis the GitHub App connection's installation id(
grants.ts:159-160, written byroomGrantService.ts:263), not anAgentInstallationid — so thegrant cannot even name the seat's installation from its own row.
The projection that can answer this lived inline inside the
/assignedhandler(
agentBinding.ts:417-440). Writing a second resolver ingrants.tsis precisely the "seconddefinition of the predicate" the TASK-063 ruling forbids, so the first commit extracts it:
backend/services/seatEnvironmentProjection.ts— the placeholder-only env/header allow-list,projectEnvironment, andprojectSeatEnvironments({ installedBy })agentBinding.tsAgentInstallationrequire and the projection helpers are gonegrants.tsThe 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
\0escape. Same string at runtime, but itmade
agentBinding.tsa binary file —grepsaid "Binary file matches" andsedrendered theseparator 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 mutationtable 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
/assignedbuilds its work list from the machine owner's active installations. The read resolvesthe same way — the seat's
botMetadata.machineId→Machine.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
unboundand itsrefusal is
nullbecause nothing was judged. Three cases are deliberately kept distinct:seat.not_installed: the scope resolved (so this is notunbound— a daemon is polling that machine), but the projection holds no row for the seat, sothere is no environment to judge and the broker reaches nobody. This is the arm Vera 69890 added,
and the test that asserted
seathere 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.
unbound, 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-server→Instance failed to start within 10000ms, which takes down a whole suite atsetup. Re-run in isolation with
jest --onlyFailureson this head:leakMatrix.agentRuntime.test.jsPASS,agentBinding.phase2.test.jsPASS. Neither is touchedby this PR (the changed files are
grants.ts,agentBinding.ts, the extracted service and the twotest files); the failure mode is instance startup under a busy box, not an assertion.
lint:ts0errors (1207 pre-existing warnings). The four suites this change affects, run on their own: 74
passed, 0 failed.
__tests__/unit/services/seatEnvironmentProjection.test.js(5) — the composite key, ownerscope, first-declaration-wins, that a scoped call cannot leak another owner's seat, the
instanceIdnormalisation (the schema lowercasesagentNamebut notinstanceId, so a rawtemplate 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 layernamed; 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
unboundwith 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_installedwith 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_evaluatedwith a seat-grant control.grantBrokerRefusal/grantBrokerRefusalScopeare pinned there rather than added silently.Eleven independent mutations, each reddening its own tests (serialized; control 74/74 green):
Quilllookup)seatscopeunboundnot_installedrelabelledseat(the pre-69890 reading)not_installedrelabelledunbound(the other wrong value)not_installedHonest limit
A refusal made by the DAEMON is still invisible here. The heartbeat keeps only four fields after
normalizeAgentStatesandMachine.tsdeclares nostrictoption, so Mongoose drops undeclaredpaths 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 ontod5afd20d(#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: trueso 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.