Skip to content

fix(local-node-host): make the forms surface unavailable to the web plane - #3374

Merged
ctwoodwa merged 2 commits into
mainfrom
eng/3367-forms-startup-identity-fence
Aug 1, 2026
Merged

fix(local-node-host): make the forms surface unavailable to the web plane#3374
ctwoodwa merged 2 commits into
mainfrom
eng/3367-forms-startup-identity-fence

Conversation

@ctwoodwa

Copy link
Copy Markdown
Contributor

Closes #3367

Step 1 first — the deputy is live, and it survives the sibling fence

The experiment, run before anything was built. With PR 3363 (card #3356) already merged into
the base
, a signed-in member's request to the forms surface was still judged by the desktop
operator's authority:

Operator's role when the host booted Member's POST /api/local-node/forms/{id}/submit
TeamRole.Admin 201 Created — the submission committed
TeamRole.Member 403 capability denied

Their read visibility of an Admin-gated field moved the same way (readable / not readable). The
member's answer was the operator's answer, and nothing about the member changed between the two runs.

That the sibling fence was already in the base is the point of this card. #3356 decorates the
single place IAuthorizationContext is composed, so it reaches code that asks an authorization
question. This family never asks: HostedFormsApiEndpoint.StartAsync captures the operator's
subject, the operator's ICurrentUser.Roles and the definition owner once, and the routes mint a
per-request capability token from those captured values. A family that has already left the seam
cannot be reached by a seam-gate, however hard that gate is hardened.

Why the flip is at boot rather than mid-run. #3356's proving shape flips the operator's
membership while the host runs, because the seam it fences re-resolves grants on every call. This
family reads the roles once, so a mid-run flip changes nothing — the equivalent lever is the role the
operator holds when StartAsync runs, which is why each half of the test builds its own host. Stated
that way the defect is if anything worse: the member does not merely borrow the operator's authority,
they borrow a stale snapshot of it.

The fence

Both route families the endpoint maps go into a desktop-plane-only route group whose endpoint
filter refuses with 403 {"code":"web-plane.route.unavailable"} while a hosted-web request principal
is bound — ADR 0160 D5, "either consumes the request principal or is unavailable". No permission
is resolved; the surface only ever gets more closed. Desktop-plane calls are untouched.

Three deliberate choices:

Testing

Four tests, each observed failing under a mutation that was run, not reasoned about:

Mutation Result
Fence condition neutered (false && …) 3 web tests red, desktop test green
Blanket refusal (true || …) desktop test red — a denial-of-everyone cannot pass
Shared carrier narrowed to mutations (skip the scope for GET) both GET-driving tests red (and #3356's own GET test)
FormDefinitionRoutes mapped outside the group the authoring test red
FormsRoutes mapped outside the group the submit + render tests red
  • Full node-host suite: 1623 passed, 0 failed, 18 skipped.
  • dotnet build apps/local-node-host -warnaserror: clean. Whole-solution build clean apart from a
    pre-existing CS0162 in apps/kitchen-sink, reproduced on an unmodified main checkout.
  • SelectedSessionRequestPrincipalTests:54-55 is not in the diff and is unchanged. Nothing is
    renamed or deleted, so there is no semantic-conflict surface.

Sweep — what else carries a principal without asking

The card asked for the shape, not the site. Scanning every StartAsync that maps routes turned up
two more instances of exactly this shape, both unfenced by either card, plus one adjacent. They
are reported, not fixed — each needs its own decision, and both are reachable by a selected-session
cookie today (neither path is on the listener allowlist):

  1. HostedAdmissionApiEndpoint — captures admitterPartyId at startup and closes it over
    AdmissionRoutes.Map. AdmissionRoutes has no caller-auth guard of its own beyond the listener,
    and consults no IAuthorizationContext. A web-plane member reaching POST /admission/invites or
    /admission/redeem mints and admits as the genesis admitter, signed with the node's key. This
    is roster mutation and it is the most serious of the three.
  2. HostedCommsApiEndpoint — captures activeMemberPartyId at startup and stamps it as the
    author of every appended message. The routes call callerAuth.Validate, but that returns Allow
    on the gate-passed marker the listener sets for a selected-session request, so it is not a plane
    boundary. A member's message is authored as the operator's roster party and signed with the node's
    key, then merged into a CRDT other nodes accept.
  3. DocumentTemplateRoutes (:136, :190) — PlacedBy = ActiveTeamAuthorizationContext.NodeOperator,
    a constant rather than a startup capture, on a route family that consults no authorization context.
    Attribution rather than authority, so lower severity, but the same "carries a principal without
    asking" family.

Two things that look like the shape and are not: NodeCallerParty.Resolve(http) reads the request
feature and falls back to the operator only when genuinely unbound — that is the correct pattern, and
it is what the financial routes use. SchedulingDefinitionRoutes closes over ICurrentUser, but every
route gates authz.HasPermission before reading it, so #3356 covers it — by gate ordering per site,
which is worth knowing is what is holding it.

One thing the sweep surfaced that belongs to nobody yet: NodeTenant.Resolve(activeTeam) means a
web-plane request's tenant is whatever team the desktop operator has in the foreground. R3-D names
that shape explicitly. It is not this card and not #3356; it may want one of its own.

Sequencing consequence. The card says fencing one deputy and not the other leaves the door open
before shipyard#3329. By the same argument, #3329 should not go live with admission and comms
unfenced. WebPlaneUnavailableRouteFence is a reusable instrument, so fencing either is a two-line
change in its hosted endpoint plus its own tests — but the decision is Admiral's, not mine, and
admission in particular may want a deliberate answer rather than a blanket refusal.

Gate chain

commitlintBuild & Testauth change_type deep review → Admiral arms. Not self-armed;
opened as DRAFT.


🤖 Generated with Claude Code

https://claude.ai/code/session_01N7ge8CQJE4tyGHQSddBYmJ

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ctwoodwa, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f9e425c2-8610-46f0-9fa1-81990f72036b

📥 Commits

Reviewing files that changed from the base of the PR and between df9df08 and cf49037.

📒 Files selected for processing (8)
  • .wolf/buglog.json
  • .wolf/buglog.jsonl
  • .wolf/cerebrum.d/20260729-1907-key-learnings-added-2026-07-29-2026-07-29-two-distinct-confu.md
  • .wolf/cerebrum.md
  • apps/local-node-host/Health/HostedFormsApiEndpoint.cs
  • apps/local-node-host/Health/WebPlaneUnavailableRouteFence.cs
  • apps/local-node-host/tests/Identity/FormsStartupCapturedIdentityFenceTests.cs
  • apps/local-node-host/tests/Identity/Mtw2TwoUserAcceptanceE2E.cs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

thought (non-blocking):

Accessibility audit (advisory)

The sharded axe audit is report-only while the baseline and runtime budget mature.
Unknown moderate-or-higher findings must be resolved or added to the reviewed
accessibility baseline.

Shard Result Findings
1/4 pass 0 axe finding(s), 1 coverage warning(s)
2/4 pass 0 axe finding(s), 1 coverage warning(s)
3/4 pass 0 axe finding(s), 1 coverage warning(s)
4/4 pass No moderate-or-higher findings
Shard 1 report
[shipyard-syncstate-indicator] skipping directionalIcons enforcement: component has not opted in via [data-shipyard-direction="<state>"] sentinel.
Shard 2 report
[shipyard-button] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".
Shard 3 report
[shipyard-dialog] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".

@ctwoodwa

Copy link
Copy Markdown
Contributor Author

Field-day triage 2026-07-31 — left open, not closed. It is a DRAFT, 29 commits behind, and its card #3367 is still open, so unlike the mid-July drafts I closed today this is live work rather than backlog wearing a PR's clothes.

Two things worth knowing before resuming it:

  1. Its sibling landed. fix(local-node-host): stamp the acting member on forms, not a constant #3437 (stamp the acting member on forms) merged today after deep review. That review confirmed NodeCallerParty.Resolve(http) is now called per-request inside the forms handlers, with the captured parameters deleted from the Map signatures so no call site can reintroduce a boot-time constant. Check whether that changes this PR's approach — the deputy this PR fences may already be narrower than when the branch was cut.

  2. The deep-review gate is now required and will fire on it. It is a required check on every shipyard PR, and it never ran on anything created before 2026-07-30T19:09Z. This branch predates that. Update the branch and expect needs-deep-review; the gate clears on a deep-review-passed label after a code-reviewer verdict.

Related open follow-up from today's reviews: #3466 (audit the 15 existing web-session consumers against the three effect-time MUSTs) — relevant if this PR touches that surface.

@ctwoodwa

Copy link
Copy Markdown
Contributor Author

This is DIRTY against main, and the conflict is semantic — it needs your call, not a mechanical rebase

I attempted the rebase (its last commit is 2026-07-29T19:08:02Z, which predates the deep-review gate
cutoff, so it is blocked until updated). The .wolf conflicts resolve mechanically. The one in
apps/local-node-host/Health/HostedFormsApiEndpoint.cs does not, and it is worth understanding
before anyone resolves it.

What changed underneath this PR

#3437 landed on main (closing #3378) and rewrote the same block. Main now resolves the form subject
and definition owner per request from NodeCallerParty.Resolve(http), where this branch was
written against a base that captured them once at StartAsync. So FormsRoutes.Map and
FormDefinitionRoutes.Map have different signatures on the two sides — main dropped the subject and
owner parameters this branch still passes.

Why this PR is NOT obsolete

Main's own comment is explicit that it fixed attribution only:

Roles still come from the resolved ICurrentUser when present … That is deliberate: this card is
ATTRIBUTION, and per-member permissions are MTW-3 (CIC 2026-07-29).

#3367's deputy is about authorization, and this PR's own experiment table is the evidence — a
member's POST /forms/{id}/submit returned 201 or 403 purely by the role the operator held at boot.
That defect survives #3437. A member is now correctly named and still incorrectly judged.

So the fence still has a job: if the surface cannot authorize a member correctly, do not serve it to
them. That is fail-closed and it is the right posture until MTW-3 lands per-member permissions.

What the resolution actually is

Keep main's per-request identity (drop the subject / owner arguments this branch passes) and
keep this branch's route builder (desktopPlaneOnly instead of app):

FormsRoutes.Map(desktopPlaneOnly, _engine, _issuer, _verifier, _activeTeam, roles, _timeProvider);
FormDefinitionRoutes.Map(desktopPlaneOnly, _definitionStore, _schemaRegistry, _activeTeam, _timeProvider);

The doc comment needs to carry both facts rather than either alone: identity is resolved per request
and the family is unavailable while a hosted-web principal is bound.

Why I did not just do it

Two reasons. It is a merge on an authorization fence, where "kept both halves" and "kept the wrong
half" look identical in a diff and differ entirely in behaviour. And the question underneath it —
given attribution is now correct, is fencing still what we want, or does #3367 get rescoped to wait
for MTW-3?
— is a design decision that belongs to whoever owns the card, not to a rebase.

If the answer is "yes, still fence", the resolution above is what I would apply, and it should draw a
deep review on the auth tier once it does.

Refs #3367, #3378, #3437

@ctwoodwa

Copy link
Copy Markdown
Contributor Author

CIC ruling: keep the fence, rebase it

Decided after the conflict analysis above. The fence stays.

Reasoning, recorded so the rebase is not re-litigated: #3437 fixed attribution and main's own comment
is explicit that per-member permissions are deferred to MTW-3. So a member is now correctly named
and still incorrectly judged — this PR's own experiment table (201 vs 403 purely on the operator's
boot-time role) is the evidence that the deputy survives. A surface that cannot authorize a member
correctly should not be served to them. Fail-closed now, unfence when authorization is real.

The resolution to apply

Keep main's per-request identity — drop the subject / owner arguments this branch still passes,
since #3437 removed those parameters — and keep this branch's route builder:

FormsRoutes.Map(desktopPlaneOnly, _engine, _issuer, _verifier, _activeTeam, roles, _timeProvider);
FormDefinitionRoutes.Map(desktopPlaneOnly, _definitionStore, _schemaRegistry, _activeTeam, _timeProvider);

The class doc must carry both facts rather than either alone: identity is resolved per request, and
the family is unavailable while a hosted-web principal is bound.

Two things for whoever does it. This is a merge on an authorization fence, where "kept both halves"
and "kept the wrong half" look identical in a diff and differ entirely in behaviour — so it wants a
deep review on the auth tier once rebased, not a rubber stamp. And note that WebPlaneUnavailableRouteFence
does not exist on main yet; it arrives with this PR, and #3382 is sequenced behind it because it
reuses the same fence.

Refs #3367, #3378, #3437, #3382

@ctwoodwa

Copy link
Copy Markdown
Contributor Author

Rebased onto main per the ruling — 7e2fe20a1

Both halves kept, which was the whole risk in this merge.

Identity is main's per-request resolution: the subject / owner arguments this branch used to
pass are gone, because #3437 removed those parameters. Exposure is this card's fence: both families
map onto desktopPlaneOnly, never onto app.

FormsRoutes.Map(desktopPlaneOnly, _engine, _issuer, _verifier, _activeTeam, roles, _timeProvider);
FormDefinitionRoutes.Map(desktopPlaneOnly, _definitionStore, _schemaRegistry, _activeTeam, _timeProvider);

The class doc now carries both facts rather than either alone, and says explicitly why: attribution is
not authorization, #3378 was scoped to the former, per-member permissions are MTW-3, so a member is
correctly named and still judged by the operator's boot-time role. A seam-gate cannot reach code
that has already left the seam.

Why I trust the merge rather than just the diff

The two ways to get this wrong — keeping only identity, or keeping only the fence — look nearly
identical in a diff. Two independent signals separate them here:

  • Keeping only the fence would not compile. The removed subject / owner parameters no longer
    exist on FormsRoutes.Map / FormDefinitionRoutes.Map. Build succeeds, so identity is main's.
  • Keeping only identity would turn the fence tests red. FormsStartupCapturedIdentityFenceTests
    drives both families on the web plane; the branch's own comment says dropping either from the
    group fails a test. 4/4 pass.

Full suite: 1693 passed, 0 failed, 18 skipped.

The .wolf conflicts were resolved with the corrected procedure — buglog.json regenerated,
cerebrum.md never deleted, hand-maintained preamble verified present afterwards. That distinction
cost two entries elsewhere today, including a Do-Not-Repeat.

What this unblocks

WebPlaneUnavailableRouteFence arrives with this PR and does not exist on main. #3382 is sequenced
behind it
and reuses it — CIC ruled to refuse admission on the web plane, so that card can start once
this lands.

Sent for deep review on the auth tier, as the ruling asked. Not armed.

@ctwoodwa

Copy link
Copy Markdown
Contributor Author

Deep review: APPROVE-WITH-FOLLOWUPS — all three major findings fixed in 1a9b1f448

The merge itself was confirmed clean: the diff against the merge-base is exactly two token swaps
(appdesktopPlaneOnly) plus comments, neither Map call passes subject/owner, and the
MapApiRoutes callback has exactly three statements so nothing in this file is still on app. All 8
endpoints are lambda handlers, so the group filter genuinely applies — no RequestDelegate-overload
routes silently skipping it.

One correction I'm taking, because it was aimed at my own verification. I argued "build succeeds ⟹
main's identity half is intact." That proves only the signature half — ActingSubject(http) and
ActingOwner(http) could be reverted to constants inside the route files and still compile, and all
four fence tests would stay green, because under the fence no web request reaches a handler. The real
pin is Mtw2TwoUserAcceptanceE2E. That distinction is now written into the code rather than left in a
review thread.

M1 — the ⚠ SECURITY banner said the opposite of what the code does — fixed

It claimed subject, roles and owner are captured at startup. Only roles is. The file carried
two mutually exclusive statements and the wrong one was the banner a future author reads first. It now
names roles alone and says explicitly not to restore a captured subject or owner to match it. Same
stale premise corrected in two places in the fence test's docs — the experiment's conclusion survives
(the role is what decides the outcome), its premise had to be narrowed.

M2 — "the whole family" was broader than the code — fixed, and the gap is now a card

This is the finding that mattered. FormDraftRoutes declares the same /api/local-node/forms route
base, registers through a different endpoint, and is not in the group — four routes open on the web
plane. Same deputy by a third mechanism: handlers resolve IPartyContext from the outer container,
so a member's draft is keyed to the operator's party and GET /api/local-node/forms/drafts returns
the operator's drafts to that member.
A read-side cross-principal disclosure, sharper than the
mint-side defects this family is about, with zero server-side tests.

Claims narrowed to the two families actually mapped here, with the gap named where the next reader will
look. Filed as #3481, and I've taken the recommendation to sequence it with or ahead of #3382
admission is a mint-side defect needing a deliberate act; this is a passive read that leaks on page load.

M3 — the acceptance E2E disagrees with production — annotated, not deleted

Mtw2TwoUserAcceptanceE2E maps both families onto its own unfenced app and asserts the joiner
succeeds; production now returns 403 for all of it. It must not be deleted — it is the only pin on the
identity half. It now states that it proves attribution, not reachability, and that production
refuses the joiner until MTW-3, so a green "two user acceptance" cannot be misread as evidence a member
can reach these routes.

m1, m2, n1 — taken

The fence's class remarks now document what it does not reach (selected-session plane only; Accept-3
legacy is deliberately excluded and why; a sibling family sharing a route base is not covered by mapping
this one), and that it is fail-OPEN by construction — spelling out that for forms that costs a
capability minted against the operator's role, but on a roster-mutation surface the same default would
silently grant genesis-admitter minting signed with the node's key. #3382 inherits that polarity and
should decide it rather than absorb it.

m1 is filed as #3482AddEndpointFilter with a delegate registers a factory, not queryable
metadata, so fence membership cannot be asserted by any test or gate. FormDraftRoutes is the proof
that matters. The machinery to fix it already exists in the executable-endpoint registry and ADR 0160
R3-I.10, and it should land before #3382 makes this primitive load-bearing on roster mutation.

Suite 1693 passed, 0 failed. Not armed.

@ctwoodwa ctwoodwa added the deep-review-passed code-reviewer deep pipeline ran and its verdict was accepted label Jul 31, 2026
@ctwoodwa
ctwoodwa force-pushed the eng/3367-forms-startup-identity-fence branch from 1a9b1f4 to 06682b4 Compare July 31, 2026 22:58
@ctwoodwa
ctwoodwa marked this pull request as ready for review July 31, 2026 22:59
@ctwoodwa
ctwoodwa enabled auto-merge (squash) July 31, 2026 22:59
@ctwoodwa
ctwoodwa disabled auto-merge July 31, 2026 23:02
ctwoodwa and others added 2 commits July 31, 2026 20:23
…lane

HostedFormsApiEndpoint captures the operator's subject, role list and definition
owner ONCE at startup and mints a per-request form capability from them. Nothing
on that path resolves an IAuthorizationContext, so the web-plane fence installed
at that seam cannot see the family: a seam-gate reaches code that ASKS an
authorization question, not code that CARRIES a captured answer.

Proven, not read. With the sibling authorization fence already merged, a member's
POST to the forms submit route returned 201 Created when the OS operator booted
Admin and 403 capability-denied when the same operator booted Member. The
member's write authority was the operator's, frozen at boot; their read
visibility of an Admin-gated field moved the same way.

Both route families are now mapped into a desktop-plane-only route group whose
endpoint filter refuses while a hosted-web request principal is bound, per ADR
0160 D5 (consume the request principal or be unavailable). The plane signal is
deliberately the same one the sibling fence reads, so the two cannot disagree.
Refusal only — no permission is resolved, so the surface only gets more closed.

The four tests were observed failing under five mutations: the fence condition
neutered (three web tests red, desktop green), a blanket refusal (desktop red),
the shared carrier narrowed to mutations (both GET-driving tests red), and each
route family in turn mapped outside the group (its own test red).

Refs: #3367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7ge8CQJE4tyGHQSddBYmJ
Three text findings from the deep review. All three are about claims that
disagree with the code, on a change whose stated guard IS the comment.

The SECURITY banner said the subject, roles and owner are captured once at
startup. Only ROLES is, since #3437 made the other two per-request -- so the file
carried two mutually exclusive statements and the wrong one was the banner a
future author reads first. It now names roles alone and says explicitly not to
restore a captured subject or owner to match it. The same stale premise is fixed
in two places in the fence test's own docs; the experiment's conclusion survives,
because the ROLE is what decides the outcome, but its premise had to be narrowed.

"The whole family" was broader than the code. FormDraftRoutes declares the SAME
/api/local-node/forms route base, registers elsewhere, and is NOT in this group
-- so four routes stay open on the web plane. It is the same deputy by a third
mechanism: its handlers resolve IPartyContext from the outer container, so a
web-plane member's draft is keyed to the operator's party and the list route
returns the operator's drafts to that member. The claim is narrowed to the two
families actually mapped here, and the gap is named where the next reader will
look. Tracked separately.

The acceptance E2E maps both families onto its own unfenced app and asserts the
joiner succeeds -- which production now refuses. It is not wrong and must not be
deleted: it is the only pin on the per-request identity half. It is now annotated
to say it proves ATTRIBUTION and not reachability, so a green "two user
acceptance" cannot be read as evidence a member can reach these routes.

Also documents what the fence primitive does NOT reach, and that it is fail-OPEN
by construction -- absence of a bound web principal means allow. Correct for a
desktop-authority surface; on a roster-mutation surface the same default would
silently grant genesis-admitter minting. The admission card inherits that
polarity and should decide it rather than absorb it.

Suite 1,693 passed, 0 failed.

Refs: #3367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7X6vvwjSyFSPBT9Ss7TgN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deep-review-passed code-reviewer deep pipeline ran and its verdict was accepted needs-deep-review Touches a change_type selector; deep review required before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MTW-2: a second confused deputy — HostedFormsApiEndpoint mints operator identity captured at startup

1 participant