SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md markdown
1,248 lines 121.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago

SEC-SEAM-1 — frozen spec: session-bound learner identity for task / media / delegation / flow writes

Phase: SEC-SEAM-1 (SEC-SEAM-1a Thinking freeze → SEC-SEAM-1b Auto build) Freeze status: CLEARED — independent reviewer pass (round 7). Escalated decisions §12 D1–D5 are RATIFIED by the operator (§12.1: D1 = A, D2 = A, D3 = start empty, D4 = A, D5 = A). Round-3's 11 findings (V1–V11), round-4's W1–W5, and round-5/6 X1–X2 / Y1 are addressed (§11.3–§11.4). D3's cost-premise was corrected as a disclosure (V1) — the empty-list outcome is unchanged. SEC-SEAM-1b (Auto) may start from this freeze. Tier-3 gates T1–T5 (§8) remain not authorized and unexecuted. Date: 2026-07-26 (authored) · 2026-07-27 (round-3 ratification; rounds 4–7 fixes; clearance) Model (this artifact): Thinking Driving finding: Pass 2 P3~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md (row P3, owner SC + KN) Owner repos: Knowtation (permission authority — rules S1–S10) and Scooling (consumer — contract C1–C6, built as L-SEAM on the Scooling board)

No review stamp is present by design. ok review --freeze writes a machine-readable review_stamp: {verdict: pass} into this block when its mechanical checklist passes. Round 1 of the semantic review (§11) correctly flagged that stamp as an authoring session recording its own clearance — the SEC-KN-4a round-2 failure shape. The stamp was removed and may only be written after a semantic reviewer records pass in §11. Mechanical-gate status: ok review --freeze blocked twice during round-1 authoring — on an absolute machine path and on a secret-like pattern — and both were fixed (§10). Round 2 recorded no run at all, and the round-1 header claim that it "is run with --dry-run between rounds" was unsupported (N16); that wording is removed rather than reworded. It was re-run at the end of the round-3 fix work. It blocked twice, both times on the same checker rule and both self-inflicted: a self-hosted refresh route written with a leading slash (the §2 notation rule exists for exactly this), and then the sentence that described that fix, which quoted the offending string. Both were rewritten to the §2 notation; the re-run returned pass, 0 findings. Round 4 re-ran it after V1–V11 / D5 fixes: again pass, 0 findings; again the run auto-wrote a review_stamp: {verdict: pass} block and this session removed it. Round 5 re-ran it after W1–W5 (pass, 0 findings; stamp removed). Round 6 re-ran it after X1–X2 (pass, 0 findings; stamp removed again). Round 7 re-ran it after Y1 (pass, 0 findings; stamp removed in this session). The round-1 F1 rule stands: a mechanical checklist passing is not a reviewer clearing the freeze. --dry-run is accepted by ok review but is undocumented in ok review --help; it does not appear to suppress the stamp on its own, so the stamp must be checked for and removed by hand after every run until §11 records a semantic pass. The mechanical gate is not a semantic clearance and never substitutes for §11.

Freeze-contract declaration

phase: SEC-SEAM-1
outputs:
- id: sec-seam-1-freeze
  path: docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md
  frozen: true
frozen_inputs:
- docs/ROADMAP.md
- docs/OVERSEER-HANDOVER.md
- docs/SEC-KN-4-DELEGATION-PRINCIPAL-BINDING-FREEZE.md
- docs/PROPOSAL-LIFECYCLE.md
- ~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md
- ~/scooling/docs/ROADMAP.md
review_stamp:
  reviewed_at: '2026-07-27T13:25:02Z'
  verdict: pass
  reviewer_mode: agent
  reviewer_model: thinking-high
  reviewer_provider: local
  kit_version: 0.1.0
  artifact_digest: sha256:286147aaca3caf9fe7579e472a87af3da5245d0f6778749ff722b2098f1d1e68
tier3_gates:
- T1 canister WASM upgrade that installs created_by (inherited from SEC-KN-4 gate T1)
- T2 merge to Muse main or a muse-mirror pull request (SD-14)
- T3 flipping SCOOLING_TASK_WRITES / SCOOLING_MEDIA_* / SCOOLING_DELEGATION_* on in production
- T4 restoring the migration hook to identity after T1 (inherited from SEC-KN-4 gate T4 / SEC-KN-4c)
- T5 admitting any new intent into the personal self-apply class (FINISH-COMPLETE-APPLY KN-b)

1. Plain-language summary

When a learner uses Scooling to create a task, attach a piece of media, or set up an agent consent, Scooling does not send Knowtation the learner's own sign-in credential. It sends one shared password that belongs to the operator, taken from an environment variable. Knowtation therefore sees every learner's request as if it came from the same single person.

Nothing is broken in production today, because these three surfaces are switched off and because Knowtation only lets people "approve their own work without a second reviewer" for one narrow kind of note proposal, which these surfaces do not match. The danger is what happens next: the planned step is to let people approve their own task and media work. If that shipped while every request arrives under one shared name, "approving your own work" would mean one shared identity approving work it cannot attribute to any actual person.

This document freezes the fix and, just as importantly, who can fix what. Scooling must send each learner's own credential — it already does exactly this for the notes review tray, so the mechanism exists and only needs reusing. Knowtation cannot detect a shared credential by looking at it, and this freeze says so plainly instead of pretending otherwise. What Knowtation can do, and what it is required to do here, is refuse to ever let one of these surfaces be self-approved unless the server's own record shows that the person approving is the same person who wrote the request — and refuse loudly, by name, rather than silently allowing it.

Technical summary

Scooling's taskWriteHubTransport.ts, mediaWriteHubTransport.ts, delegationHubTransport.ts, and flowHubTransport.ts build their write Authorization header from KNOWTATION_AUTH_TOKEN / KNOWTATION_HUB_TOKEN (taskWriteHubTransport.ts:208-212, :293-302; flowHubTransport.ts:1119), whereas hostedReviewWriteBack.ts:843-851 sends sessionResult.rawToken — the learner's own Knowtation JWT from the signed HttpOnly cookie. Knowtation resolves caller identity from the JWT sub alone (hub/bridge/server.mjs:840-847, :900-907) and marks no token class on web-session JWTs (hub/gateway/access-token-authz.mjs:28-30 distinguishes only mcp_access). A shared operator token is therefore byte-indistinguishable from a learner session, and every seam proposal lands with the same X-Actor-Id, the same canister created_by (hub/icp/src/hub/main.mo:160-169), and the same proposed_by (lib/task/task-write.mjs:499).

SEC-SEAM-1b makes session-bound identity a positive, server-derived, mint-time property (S1), makes author-equals-approver a hard admission requirement for any seam surface entering the self-apply class (S2–S3 — classification by apply-path predicate, not intent), forbids client-asserted identity as a remedy (S4–S5), names the refusals (S6), records the hosted media gap (S7), and includes self-hosted flow / flow_capture as seam surfaces (D5 = A). The systemic one-identity-per-learner property is consumer-owned and cannot be enforced server-side (§3.2).


2. Ground truth — what the code does today (file+line)

Every row below was read in this session. No row is inferred, and no row is carried over from the audit without re-reading the cited source.

Notation: HTTP route paths are written without their leading slash (api/v1/…) throughout this document, because the freeze-gate path check treats a leading-slash multi-segment path as an absolute machine path (kit source: overseer-kit/tools/freeze_reviewer/providers/base.py:15 — the kit checkout, not this repo).

# Ground truth Citation
G1 Bridge auth resolves identity from the JWT sub and nothing else — no type, scope, or class check hub/bridge/server.mjs:840-847, :900-907
G2 Only mcp_access is a distinguishable token class; web-session JWTs carry no type claim hub/gateway/access-token-authz.mjs:28-30, :121-129
G3 The gateway signs SESSION_SECRET JWTs at three sites. Two mint the web-session shape {sub, provider, id, name, role}; the third mints {sub} only, a 5-minute internal token for the gateway→bridge GitHub-token hop, presented as a bearer at :1301 hub/gateway/server.mjs:213-228 (issueToken), :277-287 (issueAccessTokenForSub), :1282 (internal hop)
G3.1 mcp_access tokens are minted at four further sites — an initial grant and a refresh/rotation path in each provider — and all four already carry type: 'mcp_access', so they are out of S1's scope. (N11: round 1 enumerated only the two initial-grant sites) hub/gateway/device-oauth-provider.mjs:204 (grant), :262 (refresh); hub/gateway/mcp-oauth-provider.mjs:240 (grant), :311 (refresh)
G4 Native OAuth code exchange mints through issueAccessTokenForSub, not a fourth shape hub/gateway/native-oauth-provider.mjs:442-445
G5 X-Actor-Id is set server-side from the verified sub; clients cannot inject it (allowlist forwards only content headers) hub/gateway/server.mjs:3204-3205, :3209-3213, :1351-1356
G6 Canister created_by reads X-Actor-Id only, no fallback, empty when absent (SEC-KN-4b, on branch) hub/icp/src/hub/main.mo:160-169
G7 Canister partition is X-User-Id, defaulting to "default" hub/icp/src/hub/main.mo:153-158
G8 The self-apply class is exactly one fingerprint: intent scooling.review_tray.approve + scooling.review:* external ref + reviewed/*.md path lib/hub-proposal-personal-self-apply.mjs:13, :16-19, :47-56
G9 Full class predicate checks vault:write, partition ownership, role, human actor, status === 'proposed', fingerprint, and elevation — but never authorship lib/hub-proposal-personal-self-apply.mjs:106-124
G10 SEC-KN-3 actor gate rejects mcp_access / actorKind: 'agent' / humanActor: false lib/hub-proposal-personal-self-apply.mjs:84-90; wired at hub/gateway/server.mjs:3102-3111
G11 partitionOwned is Boolean(proposal) — a successful partition-scoped GET, not an authorship check hub/gateway/server.mjs:3098-3106, :3036-3057
G12 Hosted task proposals: requireBridgeAuth only; userId: req.uid; author headers derived from the same req.uid hub/bridge/task-routes.mjs:251-283, :104-108
G13 Hosted delegation proposals: same shape (X-User-Id effective, X-Actor-Id actor) hub/bridge/delegation-routes.mjs:66-70
G14 Task proposed_by is server-set from input.userId, never from the client body lib/task/task-write.mjs:499, :588, :648, :713, :811, :880, :931, :1066; carried at lib/task/task-hosted-proposal.mjs:261
G15 Hosted task propose accepts learner roles — bridge member maps to handler editor hub/bridge/task-routes.mjs:40-42, :86-92
G16 Self-hosted task propose is viewer-inclusive; self-hosted delegation consent propose is viewer-inclusive hub/server.mjs:1497-1499, :1872
G17 No hosted media proposal route exists. Attachments proposal routes are self-hosted only, and editor/admin only hub/server.mjs:1304-1307, :1340; no api/v1/attachments route in hub/gateway/server.mjs
G18 An unmatched api/v1/* request falls through the gateway catch-all to the canister, which returns 404 NOT_FOUND for unknown paths hub/gateway/server.mjs:3889-3893; hub/icp/src/hub/main.mo:1760
G19 Neither the gateway nor the bridge reads a client X-User-Id or X-Actor-Id. Every occurrence in hub/bridge/** is an outbound canister header — hub/bridge/server.mjs:664, :1315, :1355, :1426, :1694-1698, :2221, :2850, :3021-3025; hub/bridge/task-routes.mjs:106, :365; hub/bridge/delegation-routes.mjs:68, :197. The only read is the trusted gateway→canister hop hub/icp/src/hub/main.mo:154
G19.5 A first-party client already sends client-asserted X-User-Id: Paperclip's createHubClient injects 'X-User-Id': userId (default 'paperclip') on every call. It is inert — the gateway sets x-user-id itself and the proxy allowlist forwards only content headers — so the invitation S4.2 warns about has already been accepted and is held closed solely by the control S4.4 freezes. Recorded so G19 is not read as "no client sends it" deploy/paperclip/skills/hub-client.mjs:5, :20, :65; gateway set/forward hub/gateway/server.mjs:3204-3213, :1351-1356
G19.1 X-User-Id is nevertheless advertised in CORS Access-Control-Allow-Headers on both JS surfaces, and in the canister's own CORS headers hub/bridge/server.mjs:872; hub/gateway/cors-middleware.mjs:60; hub/icp/src/hub/main.mo:221
G19.2 The canister proposal GET serializes frontmatter and created_by; the proposal list serializes neither frontmatter nor body. Neither serializer emits task_meta, and task_meta / proposal_kind appear zero times in the canister source hub/icp/src/hub/main.mo:1156 (GET), :1137 (list)
G19.3 Task proposal frontmatter is server-set ({type, task_id, proposal_kind}), while intent is free-form client text validated only for non-emptiness frontmatter lib/task/task-write.mjs:494, :583, :643, :708, :806, :875, :926, :1056-1061; intent lib/task/task-write.mjs:374-377, passed through at hub/bridge/task-routes.mjs:268
G19.4 On the generic proposal-create path the client controls both intent and frontmatter — the canister extracts frontmatter straight from the POST body hub/icp/src/hub/main.mo:1367 (proposals-POST branch), :1370 (intent), :1371 (extractFrontmatterFromPostBody), :1391 (stored); generic path hub/gateway/server.mjs:3889-3893
G27 The hosted task apply hook and the seam classifier must share a predicate. maybeApplyHostedTaskAfterApprove fires iff normalizeCanisterProposalForTaskPrecheck(proposal) != null; that normalizer triggers on frontmatter.knowtation_proposal_source or proposal.source or a meta/tasks/proposals/ path prefix, and resolves its kind from frontmatter.task_proposal_kindproposal.task_meta.proposal_kindJSON.parse(proposal.body).proposal_kind. task_proposal_kindproposal_kind — this key difference is the N1 parser differential hook hub/gateway/task-approve-hosted.mjs:17-19, :56, invoked hub/gateway/server.mjs:3386; normalizer lib/task/task-hosted-proposal.mjs:85-163; key constants :18-23; merge :60-77
G28 The hosted delegation apply hook dispatches on isDelegationProposalIntent(intent) alone, while normalizeCanisterProposalForDelegationPrecheck also accepts the frontmatter marker and proposal.source. The normalizer is therefore a strict superset of the hook trigger: a matching intent sets fromIntent, and delegationRecordKindFromIntent returns a non-empty kind for both intents, so the normalizer can never return null when the hook would fire (verified by execution, §10) hook hub/gateway/delegation-approve-hosted.mjs:50-51, invoked hub/gateway/server.mjs:3370; normalizer lib/agent/delegation-hosted-proposal.mjs:100-137; intents :21-24; kind map :44-48
G29 The self-hosted approve path dispatches all three seam applies on proposal.source alone — no frontmatter or body parsing hub/server.mjs:3072 (delegation), :3085 (task), :3093 (media); constants lib/task/task-write.mjs:44 ('task'), lib/attachments/attachment-write.mjs:49 ('media'), lib/agent/delegation.mjs:36 ('delegation')
G30 There is no hosted media apply hook. Only two maybeApply*AfterApprove hooks exist (task, delegation), consistent with there being no hosted media route (G17) hub/gateway/server.mjs:3370, :3386; exhaustive search for maybeApply*AfterApprove returns only hub/gateway/task-approve-hosted.mjs:36 and hub/gateway/delegation-approve-hosted.mjs:29
G31 The self-hosted proposal store persists frontmatter as an object (not a JSON string) and retains task_meta, delegation_meta, and media_meta, each carrying proposal_kind / record_kind. parseProposalFrontmatter already accepts both the object and string shapes, so one predicate covers both stores hub/proposals-store.mjs:212 (frontmatter), :256-270 (delegation_meta), :271-293 (task_meta), :294-300 (media_meta); dual-shape parse lib/task/task-hosted-proposal.mjs:29-45
G32 Self-hosted approve fires five index applies — task, delegation, media, flow, flow_capture — all on proposal.source, all behind the same requireApproveRole self-apply gate apply hub/server.mjs:3056 (flow), :3064 (flow_capture), :3072 (delegation), :3085 (task), :3093 (media); gate :467-491
G33 Flow propose sets source: FLOW_PROPOSAL_SOURCE ('flow'); the client controls intent, external_ref, and vault_mirror_path (mirror path becomes proposal.path). Capture propose sets source: FLOW_CAPTURE_PROPOSAL_SOURCE ('flow_capture'). Gateway has no flow propose write route — authoring is self-hosted only propose lib/flow/flow-authoring.mjs:299-303, :397, :407-414; capture source lib/flow/flow-capture.mjs:49; gateway write search: only hub/gateway/server.mjs:1017 (external-grants), no propose
G34 Scooling Flow authoring write uses KNOWTATION_HUB_TOKEN — the identical P3 shared-env-token shape. FLOW_AUTHORING_WRITES defaults off ~/scooling/src/adapters/flowHubTransport.ts:1119; gate lib/flow/flow-authoring.mjs:171-174
G35 A machine-credential mint path exists today: signServiceJwt mints {sub, role: 'service'} with SESSION_SECRET, no type claim (classifies legacy_session) netlify/functions/consolidation-scheduler.mjs:72-73, used :146
G36 A fifth learner-session mint site: issueLocalToken mints the web-session shape {sub: 'local:…', provider, id, name, role} on interactive local sign-in; mounted on both surfaces via registerLocalAuthRoutes mint hub/lib/local-auth.mjs:179-192, called :401; mounts hub/gateway/server.mjs:510, hub/server.mjs:625
G37 Beyond the two maybeApply*AfterApprove hooks (G30), post-approve apply triggers exist: gateway proxies api/v1/delegation/proposals/:proposal_id/apply-approved; bridge owns task and delegation apply-approved routes. Both bridge handlers pass requireApproved: true (or default), so they are not a self-apply bypass — the approve-time gate still stands gateway proxy hub/gateway/server.mjs:1049; bridge hub/bridge/task-routes.mjs:351 (requireApproved: true at :370); hub/bridge/delegation-routes.mjs:181 (requireApproved: true at :202)
G20 Scooling task writes build Authorization from KNOWTATION_AUTH_TOKEN ?? KNOWTATION_HUB_TOKEN ~/scooling/src/adapters/taskWriteHubTransport.ts:208-212, :293-302, used at :326
G21 Scooling media and delegation writes use the same env-token pattern ~/scooling/src/adapters/mediaWriteHubTransport.ts:112-115, :314-322; ~/scooling/src/adapters/delegationHubTransport.ts:486-489, :344-355
G22 Scooling notes self-apply is the contrast case — it passes sessionResult.rawToken, the learner's own Knowtation JWT, as the bearer ~/scooling/src/adapters/hostedReviewWriteBack.ts:843-851
G23 The reusable per-learner mechanism already exists: cookie → resolveHostedAuthSessionContextrawToken; calendar already substitutes a session token for the env token ~/scooling/src/adapters/scoolingHostedAuth.ts:373-417; ~/scooling/src/adapters/calendarHubTransport.ts:407-427
G24 The Scooling task write UI passes a synthetic actor, not a session: scoolingUid: "f".repeat(64) ~/scooling/src/tasks/taskWriteSurface.ts:65-68, used at :222-272; routes at app/routes/tasks.tsx:23-27
G25 All three Scooling hosted transports default to the gateway origin https://api.knowtation.store ~/scooling/src/adapters/taskHubTransport.ts:31; mediaHubTransport.ts:30; delegationHubTransport.ts:40
G26 scooling_uid is an in-Scooling HMAC of provider:providerId and is not transmitted to Knowtation on any transport ~/scooling/src/adapters/identityAdapter.ts:242-251

2.1 Why P3 is not a live exploit today — and what D4 = A changes about overlap

Honest seam proposes do not match the fingerprint. The self-apply class requires intent === 'scooling.review_tray.approve' plus the external-ref and path shape (G8). Honest task / media / delegation / flow proposes set different intents (and different default paths). The SEC-KN-4 test asserts the delegation case directly (test/sec-kn-4-delegation-principal-binding.test.mjs:934-942). Production env for the task / media / delegation surfaces is absent (Pass 2 F5(a); Scooling roadmap surface table); FLOW_AUTHORING_WRITES defaults off (G34).

Overlap is possible, and D4 = A makes it seam. Frontmatter is client-controlled on the canister proposals-POST (hub/icp/src/hub/main.mo:1371) and returned by the GET serializer (:1156). A proposal can therefore carry the review-tray fingerprint and knowtation_proposal_source + task_proposal_kind (or another S3.1 apply marker). Reproduced by execution in round 4: normalizeCanisterProposalForTaskPrecheck returns non-null and matchesScoolingReviewTrayFingerprint / isPersonalSelfApplyClass return true for that shape. Under S6.1 that proposal refuses at the seam steps (7–11) instead of passing the fingerprint — the boolean flips. Fail-closed in direction; not a hole. It is not a regression of the honest notes tray (S2.4 / §9 R3), which carries no seam apply marker and never enters those steps.

P3 remains a precondition defect, not a live exploit for honest seam proposes — exactly as the audit records it. The consequence of shipping the widening first is stated in §3.3. The overlap case is an intentional consequence of D4 = A / S3.0 and is accepted in S6.1 and §9 R10.

2.2 What the shared token actually collapses today

Even with self-apply out of reach, the shared credential already degrades three properties. These are consequences of G20–G21 plus G6/G7/G14, not speculation:

Collapsed property Consequence
Partition (X-User-Id) Every learner's seam proposal lands in the operator identity's partition, so learners can read each other's pending proposals through the ordinary partition-scoped list
Authorship (created_by, proposed_by) Attribution names the operator, so the audit trail cannot answer "which learner asked for this"
Role Every learner inherits the operator's role, including any admin allowlist entry (hub/gateway/access-token-authz.mjs:55-66, :75-77) rather than their own

3. Frozen trust model

Term Definition Source of truth
Learner The human using Scooling Knowtation JWT sub (provider:id) — never a Scooling-derived id
Session-bound credential A credential minted by Knowtation for one identity through an interactive sign-in, presented on that identity's behalf JWT class claim (S1) — not inferable from an unmarked token
Author The verified session identity that created the proposal Canister created_by (hosted) / proposed_by (self-hosted) — never the request body
Approver The verified session identity that approves Gateway x-actor-id / req.user.sub
Seam surface Task, media, delegation, flow, and flow_capture proposal surfaces (§4 S3) The apply path's own dispatch predicate (S3.1) — never a field list written in this document

3.1 Inherited assumptions (stated, not silently assumed)

  1. The canister trusts gateway-minted headers, guarded only by X-Gateway-Auth. Everything in this freeze that relies on created_by inherits SEC-KN-4 §3.2(1) unchanged: it is exactly as trustworthy as X-User-Id partitioning, which is acceptable only because SEC-KN-1 made the empty-secret branch deny and SEC-KN-0 verified the secret is set.
  2. created_by is not live until the SEC-KN-4 Tier-3 canister upgrade (gate T1). Until then every hosted created_by is "" (G6). Every rule in this freeze that reads authorship must therefore fail closed on empty, never fall back to the partition owner — the fallback the operator already rejected as SEC-KN-4 D1.
  3. PROXY_HEADER_ALLOWLIST (G5) is the control that keeps x-actor-id un-injectable. If it is ever widened, S4 collapses with it.

3.2 The honesty clause — what Knowtation cannot verify

Knowtation cannot determine whether the bearer it received is held by the human currently using Scooling. A copied learner token and a live learner session are identical on the wire (G1, G2). No rule in this freeze claims otherwise, and SEC-SEAM-1b must not implement any check that pretends to detect it.

Two consequences the build phase must respect:

  • The one-identity-per-learner property — correct partitioning and correct attribution for every learner — is achievable only by the consumer sending per-learner credentials (C1–C4). Knowtation's contribution is to make those credentials work, to make the absence of session binding fail closed where the decision matters, and to make a dedicated machine credential explicitly ineligible by class (S1) — including the present signServiceJwt path (G35), which today carries no type and therefore classifies legacy_session.
  • That is not the same as saying Knowtation has no server-side control over the shared-identity case. It has one, and S10 adopts it: the operator can declare the shared subject self-apply-ineligible. Declaration is not detection — it depends on the operator knowing which account backs the token, which they do. Freeze review round 1 (F5) was right that an earlier draft of this clause overclaimed by saying the consumer fix was the only remedy.
  • S10's reach is narrower than it first appears (N14), and it is dormant in this phase (D3). The indistinguishability above applies to S10 itself: S10 keys on the approver's sub. The shared consumer token (KNOWTATION_AUTH_TOKEN / KNOWTATION_HUB_TOKEN) is a JWT for a human operator account. A separate machine-credential mint path does exist — signServiceJwt (G35) — but it is unused for those consumer env tokens today, carries no type, and is already self-apply-ineligible under S1 as legacy_session. Listing the operator's human sub therefore still disables that human's own legitimate self-apply from their own browser, because the server cannot tell the two uses of that sub apart. That is the same wall, not an exception to it. (Round-3 V1: earlier drafts claimed "there is no machine-credential mint path"; that premise was false and is withdrawn. D3's empty-list outcome is unaffected.) Per the ratified D3, HUB_SELF_APPLY_INELIGIBLE_SUBS ships empty, so in SEC-SEAM-1b S10 bars nobody; populating it is Tier 3 (T3). No claim in this document may say S10 "closes" the shared-identity case without both qualifications.
  • The tempting server-side "fix" — letting Scooling assert the learner id alongside a service token — is forbidden (S4). It is client-asserted identity, the precise failure shape SEC-KN-4 exists to close, and it would be strictly worse than the current state because it would look authoritative.

3.3 Why the admission gate is the load-bearing rule

The widening step (FINISH-COMPLETE-APPLY-KN-b) admits new intents into the self-apply class. Today that class checks partition ownership but never authorship (G9). Under a shared credential the service identity is the partition owner and the author and the approver, so the existing predicate would return true for a proposal no identifiable human authored.

Adding author must equal approver does not, on its own, defeat a single shared identity acting alone — that is exactly why §3.2 exists. What it does defeat is the mixed case that widening creates: a proposal created under the shared service credential can never be self-applied by a learner session, and a proposal created by a learner can never be self-applied by the service. Combined with S1 (class marking) and T5 (admission is Tier 3), the class cannot be widened into the P3 hole by accident.

3.4 Deviation from the driving finding's fix wording

Pass 2 P3 prescribes: "Gateway must reject service-token proposals from the self-apply class" (~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md:81), and docs/ROADMAP.md:49 repeats it as the Knowtation deliverable. Taken literally, that instruction cannot be implemented, and SEC-SEAM-1b must not pretend to implement it: there is no property of an inbound request that identifies it as carrying a "service token" (G1, G2, §3.2). A gateway check that claimed to detect one would be a guess wearing the costume of a control.

What replaces it, in descending order of directness:

  1. S10 — the operator declares the shared subject ineligible. This is the closest thing to the literal remedy, with the detection step relocated from the server to the human who already has the knowledge. It is dormant in SEC-SEAM-1b: per ratified D3 the list ships empty, and per N14 populating it also disables the named human's own self-apply. So the audit's wording is not satisfied by this phase either — it is made available to a later Tier-3 operator action.
  2. S2 — author must equal approver, which makes any mixed service/learner combination ineligible regardless of declaration.
  3. S1 — a class marker so a dedicated machine credential is ineligible by construction. The present signServiceJwt mint (G35) already lacks type and classifies legacy_session; a future mint that stamps a non-session class is barred the same way.

docs/ROADMAP.md:49 already carries the §3.4 disclaimer ("the audit's literal 'reject service-token proposals' wording cannot be implemented…"). The closing commit does not re-argue that point; it refreshes the SEC-SEAM-1 status row (round-4 state, D5 = A ratified, reviewer verdict) and adds the hosted-media roadmap row required by ratified D2 = A (S7.5). This follows the SEC-KN-4a precedent for deviating from audit fix wording (docs/SEC-KN-4-DELEGATION-PRINCIPAL-BINDING-FREEZE.md:115-126). (W4: earlier drafts of this paragraph treated line 49 as still describing the impossible deliverable; that premise is stale.)


4. Frozen rules — SEC-SEAM-1b implements exactly these

S1 — Session binding is a positive, mint-time, server-set property

§12 D1 is ratified as option A (§12.1). S1 is therefore unconditional: the mint claim ships. No option-B or option-C variant of this rule exists, and SEC-SEAM-1b must not implement one. (This replaces the round-1 conditional that N2 flagged as leaving Auto with no spec under a C selection, and removes that clause's incorrect description of S1.4 as a "negative test" — S1.4 is an acceptance-preserving rule, not a test.)

  1. Add type: 'session' to the payload minted at all five learner-session mint sites — the four OAuth/refresh sites plus local-auth. Native OAuth inherits through issueAccessTokenForSub (G4) and needs no separate change. (V2: round 3's "all four" enumeration missed issueLocalToken.)

    | Mint site | Role | | --- | --- | | hub/gateway/server.mjs:213-228 (issueToken) | hosted login | | hub/gateway/server.mjs:277-287 (issueAccessTokenForSub) | hosted refresh / native OAuth exchange | | hub/server.mjs:308-316 (issueToken) | self-hosted login | | hub/server.mjs:326-332 (issueAccessTokenForSub) | self-hosted refresh (POST auth/refresh) | | hub/lib/local-auth.mjs:179-192 (issueLocalToken) | local / offline-locked interactive sign-in; mounted via registerLocalAuthRoutes at hub/gateway/server.mjs:510 and hub/server.mjs:625 (G36) |

    Both refresh sites are mandatory (N9). Stamping only the login sites would leave every refreshed token legacy_session. After T5 admits a seam surface, a learner's seam self-apply would then silently begin refusing after their first token refresh — the same failure class V2 records for unstamped local-auth. In SEC-SEAM-1b itself every seam proposal already refuses at S6.1 step 11 (W3), so refresh stamping does not change eligibility this phase; it is still mandatory so T5 cannot ship onto a half-stamped mint surface. The build must not treat the refresh sites as optional or "the same site". (Y1: earlier wording claimed S2.1 identified self-hosted as having "live effect before T1"; that is withdrawn — see S2.1 / X1.)

    issueLocalToken is mandatory (V2). Unstamped, every local-auth / offline-locked session is legacy_session, so after T5 those users' seam self-apply would refuse permanently — the same silent failure N9 was raised to prevent for refresh.

    Do not stamp the third gateway mint site (hub/gateway/server.mjs:1282) — it is an internal 5-minute gateway→bridge hop, not a learner session, and it must classify as legacy_session so it can never satisfy S2 (G3). Do not stamp signServiceJwt (netlify/functions/consolidation-scheduler.mjs:72-73) — it is a machine credential (G35) and must remain legacy_session. A unit test pins both classifications.

  2. Add resolveActorTokenClass(payload) to hub/gateway/access-token-authz.mjs, returning exactly one of 'session' | 'mcp_access' | 'legacy_session' | 'unknown':

    • 'mcp_access' when isMcpAccessPayload(payload) (reuse :28-30; do not re-implement)
    • 'session' when payload.type === 'session'
    • 'legacy_session' when payload is an object with a non-empty string sub and no type
    • 'unknown' otherwise, including null / non-object
  3. Add isSessionBoundActor(payload) returning true only for 'session'. Every other class, including 'legacy_session', returns false.

  4. 'legacy_session' remains fully accepted for authentication and propose — no request that succeeds today may begin failing because of S1. Its only effect is S2 eligibility.

  5. Token verification must not require the claim. Unknown or missing claims never cause a 401.

Frozen rationale (do not "improve" this in build): the claim is a necessary condition for a machine-credential class to be barred by name — including the present signServiceJwt path (G35) and any future dedicated mint. It is not sufficient to detect a copied learner token (§3.2). Do not add heuristics — iat freshness, IP pinning, user-agent checks — to make it look sufficient.

S2 — Author-bound admission to the self-apply class

Extend lib/hub-proposal-personal-self-apply.mjs:

  1. Add optional inputs to isPersonalSelfApplyClass: authorActorId, approverActorId, sessionBound.

  2. When isSeamSurfaceProposal(proposal) returns true (S3.1), all of the following must hold or the class is refused:

    • sessionBound === true
    • authorActorId is a non-empty string after trim
    • approverActorId is a non-empty string after trim
    • authorActorId === approverActorId after trim (exact, case-sensitive — uids are opaque)

    The trigger is isSeamSurfaceProposal, not the proposal's intent (N4). Round 1 left this clause reading "when the proposal's intent is in SEAM_SURFACE_INTENTS"; implemented literally that reinstates the F3/N1 defect, because intent is client-controlled on the generic create path (G19.4) and is not what any apply hook dispatches on (G27–G29). There is no intent set any more (S3).

  3. The seam evaluation runs before the fingerprint check, so a seam proposal is refused with a named reason (S6) rather than incidentally failing the fingerprint. Its exact position in the refusal order is frozen in S6.1 (N6) — it is not first overall, because it must run after the guards that today decide the same boolean.

  4. Non-seam proposals keep today's behavior exactly. The existing notes review-tray path must not gain a new refusal — see §9 (R3) for why author-binding the notes path is deliberately deferred. A notes review-tray proposal is non-seam under S3.1 (its source is none of task/delegation/media and it carries no task or delegation frontmatter marker), so it never enters the seam branch.

  5. Delegation proposals are refused unconditionally, before any other seam check, regardless of authorship or session binding — SEC-KN-4 R9 restated, not re-derived. The test is isDelegationSurfaceProposal(proposal) (S3.1), not an intent-name comparison.

S2.1 — Frozen input sources (both call sites)

The build does not choose where these values come from.

Input Hosted — hub/gateway/server.mjs:3102-3111 Self-hosted — hub/server.mjs:476
authorActorId proposal.created_by from the canister GET (G19.2). Empty until gate T1 — refuse, never substitute the partition owner (§3.1(2)) proposal.proposed_by (hub/proposals-store.mjs:184-185)
approverActorId uid — the same getUserId(req) already used at :3071 for x-actor-id req.user?.sub (already read at hub/server.mjs:469)
sessionBound isSessionBoundActor(payload) where payload is the verified bearer payload. resolveHostedActorRole (definition begins hub/gateway/server.mjs:2949; :3077 is only the call-site destructure — N17) returns {role, mayApproveProposals, isMcpAccess}, so extend it to return the payload rather than adding a second verify call. It already verifies and holds the payload in bearerPayload (:2953, :2958), so this is a return-shape change only. Both returns must include the payload — the mcp_access early return at :2969 and the main return at :3025 (W1); omitting :3025 leaves sessionBound undefined for every non-agent approve, i.e. every case S2 exists for. When bearerPayload is null (verify threw, or no bearer — :2953-2962), sessionBound is false: isSessionBoundActor(null) returns false by S1.3 ('unknown'). No call site may treat null payload as session-bound (V11) isSessionBoundActor(req.user)req.user null/absent ⇒ false. Self-hosted import disposition (W2): hub/server.mjs may import isSessionBoundActor / resolveActorTokenClass from hub/gateway/access-token-authz.mjs. That module has no imports and no top-level side effects (verified); it is the one authorized hub/server.mjshub/gateway/** edge this phase adds beyond today's ./gateway/note-facets.mjs. Do not resolve this by copying the class logic into hub/server.mjs

S2 discrimination is code-level only until T5 (X1). Self-hosted proposed_by is populated today (G14) while hosted created_by is empty until T1, so the two paths return different refusal codes when a seam proposal is well-formed for authorship (SELF_APPLY_NOT_ADMITTED after passing S2's session/author clauses on self-hosted, vs SELF_APPLY_AUTHOR_UNVERIFIED on hosted). Because W3 freezes admission empty, neither path yields an eligible seam result in SEC-SEAM-1b — step 11 refuses every seam proposal. Do not read this paragraph as "self-hosted seam self-apply can succeed before T5." The phase's sole live eligibility change is the V3 overlap flip (fingerprint ∧ seam marker: eligible today → refused under S6.1). S2's author/session clauses are as dormant for eligibility this phase as S10 is (S10.4); they become load-bearing when T5 admits a surface.

S3 — Seam classification reuses the apply path's own predicate

§12 D4 is ratified as option A (§12.1). This rule replaces round 1's SEAM_SURFACE_INTENTS set and its frontmatter.proposal_kind classifier, both of which N1 and N3 refuted.

S3.0 — The frozen anti-drift rule

No hand-written seam field list, key list, intent list, or kind list may exist in the code SEC-SEAM-1b ships. Classification must be expressed by calling the same predicate the apply path calls, or by testing the same field the apply path dispatches on.

This is the load-bearing rule of S3, and it is frozen. Round 1 wrote a parallel list keyed on frontmatter.proposal_kind; the task apply hook keys on frontmatter.task_proposal_kind (G27). They are different keys, honest hosted proposals carry both (mergeTaskFrontmatter lib/task/task-hosted-proposal.mjs:60-77), so the parallel list passed every honest test and failed only on crafted input. The gap between a hand-written list and the list the effect honors is the vulnerability. A list cannot be kept in sync by discipline, so the build is forbidden from creating one.

SEAM_SURFACE_INTENTS is deleted, not renamed. N3 established that ten of its twelve entries were proposal_kind values rather than intents, which made the intent branch of the round-1 union inert for task and media; the correct fix under D4 = A is that no such set exists.

S3.1 — The predicate

isSeamSurfaceProposal(proposal) returns true if any of the following holds. The list is a union and is exhaustive over both stores:

# Condition Store / surface
1 normalizeCanisterProposalForTaskPrecheck(proposal) != null hosted task — identical to the hook's own isTaskProposal (G27)
2 normalizeCanisterProposalForDelegationPrecheck(proposal) != null hosted delegation — proven superset of the hook trigger (G28)
3 proposal.source === TASK_PROPOSAL_SOURCE self-hosted task (G29)
4 proposal.source === DELEGATION_PROPOSAL_SOURCE self-hosted delegation (G29)
5 proposal.source === MEDIA_PROPOSAL_SOURCE self-hosted media (G29)
6 proposal.source === FLOW_PROPOSAL_SOURCE self-hosted flow (G32, G33) — §12 D5 = A
7 proposal.source === FLOW_CAPTURE_PROPOSAL_SOURCE self-hosted flow_capture (G32, G33) — §12 D5 = A

isDelegationSurfaceProposal(proposal) returns true for conditions 2 or 4 only. S2.5 uses it for the unconditional delegation refusal.

Correspondence requirement (the reason this is safe). Every condition above is a superset of an approve-time apply trigger, and every approve-time apply trigger is covered. Post-approve apply-approved routes (G37) are not approve-time triggers — they require status === 'approved' and are therefore not a self-apply bypass; they are recorded so G30's hook-only search is not mistaken for an exhaustive apply-trigger enumeration (V7).

Apply trigger Cited at Covered by
Hosted task apply (approve hook) hub/gateway/task-approve-hosted.mjs:17-19, :56 #1, same function
Hosted delegation apply (approve hook) hub/gateway/delegation-approve-hosted.mjs:50-51 #2, superset (G28)
Hosted media apply does not exist (G30) n/a — S7
Self-hosted task apply hub/server.mjs:3085 #3, same expression
Self-hosted delegation apply hub/server.mjs:3072 #4, same expression
Self-hosted media apply hub/server.mjs:3093 #5, same expression
Self-hosted flow apply hub/server.mjs:3056 #6, same expression
Self-hosted flow_capture apply hub/server.mjs:3064 #7, same expression
Post-approve task apply-approved hub/bridge/task-routes.mjs:351 (requireApproved: true) n/a — not approve-time (G37)
Post-approve delegation apply-approved hub/bridge/delegation-routes.mjs:181; gateway proxy :1049 n/a — not approve-time (G37)

A proposal that can trigger a seam apply at approve time is therefore seam by construction. A security-tier test must assert this correspondence directly (§7 tier 7).

Import safety (checked, not assumed). lib/hub-proposal-personal-self-apply.mjs may import the apply-path predicate modules and the S10 parser module under lib/not hub/gateway/** (V10). Nothing in the dependency closure of lib/task/task-hosted-proposal.mjs, lib/agent/delegation-hosted-proposal.mjs, lib/attachments/attachment-write.mjs, lib/flow/flow-authoring.mjs (for FLOW_PROPOSAL_SOURCE only — a constant export; do not import precheckApprovedFlowProposal, which needs dataDir and store I/O), or lib/flow/flow-capture.mjs (for FLOW_CAPTURE_PROPOSAL_SOURCE only) may import the self-apply module. The combined import was verified in round 3 for the first three modules; round 4 extends the obligation to the flow constant imports and forbids any new lib/**hub/gateway/** edge. If the build hits a cycle, it must stop and escalate — it may not resolve it by copying a field list into the self-apply module (S3.0).

Fail-closed rule. If any predicate throws, the proposal classifies as seam (obligations imposed). Never as non-seam. The predicates are total over arbitrary input today — each begins with an object guard (lib/task/task-hosted-proposal.mjs:86; lib/agent/delegation-hosted-proposal.mjs:101) and each parses defensively (parseProposalFrontmatter:29-45, body parse :145-154) — so the try is a guard against future change, not a known throw.

S3.2 — Why this closes the evasion round 1 left open

The N1 proposal — matches an admitted allowlist entry, omits proposal_kind, sets knowtation_proposal_source and task_proposal_kind — classifies seam under S3.1 condition #1, because that is precisely the condition under which the apply hook fires. Reproduced by execution in this session (§10): the round-1 rule classifies it non-seam, the S3.1 rule classifies it seam.

The residual argument round 1 made — "renaming the intent costs the attacker eligibility" — is withdrawn, not repaired. N3 showed it was empty: a real task proposal's intent was never in the round-1 set, so renaming it cost nothing. Under S3.1 the attacker's problem is different and real: to get the task apply to fire they must satisfy normalizeCanisterProposalForTaskPrecheck, and satisfying it is what makes them seam. There is no field they can add to trigger the effect that does not also trigger the classification, because it is the same function.

What still carries the load. The self-apply class remains a positive allowlist admitted at gate T5, and S2's obligations attach to seam classification at evaluation time. S3.1 is no longer a "belt-and-braces second check" behind the allowlist (round 1's framing); with D4 = A it is the primary seam control, and the allowlist is the independent one.

S3.3 — Self-hosted input shape (N10)

The self-hosted store persists frontmatter as an object and retains task_meta / delegation_meta / media_meta (G31), whereas the canister serializes frontmatter as a string. Round 1's parse-failure rule presumed a string and left the self-hosted shape unspecified.

Under S3.1 this question dissolves for all three surfaces: parseProposalFrontmatter (lib/task/task-hosted-proposal.mjs:29-45) already accepts object, string, and neither, and the self-hosted conditions #3–#5 read proposal.source, which the self-hosted store sets directly (hub/proposals-store.mjs:214). No shape-specific branch is needed and none may be added.

S4 — Client-asserted identity is forbidden, and the advertisement is removed

  1. No gateway or bridge route may read a client-supplied X-User-Id, X-Actor-Id, X-Scooling-Uid, or any body field, as the source of actor identity. This is the current behavior (G5, G19); S4 freezes it and requires a test that fails if it regresses.
  2. Remove X-User-Id from the CORS Access-Control-Allow-Headers on both JavaScript surfaces — hub/gateway/cors-middleware.mjs:60 and hub/bridge/server.mjs:872. Neither reads it (G19); advertising it invites a consumer to build the forbidden remedy. That invitation has already been accepted once — Paperclip's Hub client sends the header today (G19.5) — and is inert only because S4.4 / the proxy allowlist hold. Removing the advertisement still stands; changing Paperclip to stop sending the header is out of scope for SEC-SEAM-1b (record-only). The gateway is the surface that matters most, because all three Scooling transports target the gateway origin, not the bridge (G25). This is the only production behavior change in S4 and it removes a capability rather than adding one.
  3. The canister's own CORS header (hub/icp/src/hub/main.mo:221) is left as-is, deliberately. The canister is reachable only behind X-Gateway-Auth, it is the one component that legitimately does read X-User-Id (G19), and changing it would require a WASM upgrade (gate T1) for a cosmetic fix. Recorded here so a later phase does not read the omission as an oversight.
  4. PROXY_HEADER_ALLOWLIST (hub/gateway/server.mjs:1351-1356) must not be widened by this phase.

S5 — Seam propose keeps accepting learner roles

Per-learner tokens carry the learner's own role, which is weaker than the operator's. SEC-SEAM-1b must not raise the role bar on seam propose paths; hosted task propose maps member → editor (G15) and self-hosted task and delegation consent propose are viewer-inclusive (G16). A test must assert that a member/viewer actor can still propose on each reachable seam surface, so the consumer's switch to learner tokens does not silently start returning 403.

Explicitly out of scope: changing MEDIA_WRITE_ROLES (hub/server.mjs:1304) from editor/admin. Media role posture is a separate decision (§12 D2).

S6 — Named, loud refusals

Introduce these codes; each must be returned with an HTTP 403 and must never be collapsed into a generic FORBIDDEN:

Seam codes — HTTP-visible. Returned with HTTP 403 and never collapsed into a generic FORBIDDEN:

Code Meaning
SELF_APPLY_SESSION_BINDING_REQUIRED Seam proposal, approver's credential is not session-bound (S2.2 first clause)
SELF_APPLY_AUTHOR_UNVERIFIED Seam proposal, authorActorId empty — includes every hosted proposal before Tier-3 gate T1
SELF_APPLY_AUTHOR_MISMATCH Seam proposal, author and approver are both present and differ
SELF_APPLY_DELEGATION_REFUSED Delegation surface proposal — unconditional (S2.5); replaces the overloaded SELF_APPLY_INTENT_NOT_ELIGIBLE for this case (V9)
SELF_APPLY_NOT_ADMITTED Seam proposal not admitted at T5; in this phase refuses every seam proposal (V9, W3)

SELF_APPLY_INTENT_NOT_ELIGIBLE is deleted, not renamed. It named a concept S3.0 abolished (intent eligibility) and collapsed two structurally different refusals the operator cannot distinguish in logs (V9).

Admission source — frozen empty in this phase (W3). S6.1 step 11's "not admitted at T5" conjunct is unconditionally true for every seam proposal until a later T5 widening freeze ships an admission predicate. SEC-SEAM-1b must implement step 11 as an unconditional seam refusal (no admission input on personalSelfApplyRefusalReason, no env, no config file, no allowlist). Introducing any admission surface in this phase is a scope violation (S9).

SELF_APPLY_SUBJECT_INELIGIBLE is internal-only, not HTTP-visible (N7). Round 1 listed it in the seam table while S10.2 applied it to seam and non-seam proposals alike — a contradiction of this section's own "none may change what the caller returns over HTTP" rule, and a disclosure of blocklist membership to the caller. Resolved in the safer direction: the code is named in server-side logs and returned to internal callers, but the HTTP response stays the generic 403 FORBIDDEN. Naming a refusal loudly is for the operator reading logs, not for an unauthenticated prober enumerating who is on the list.

Non-seam codes — internal only. isPersonalSelfApplyClass returns false today for seven ordinary conditions (lib/hub-proposal-personal-self-apply.mjs:106-124), none of which is an anomaly. Each needs a name so personalSelfApplyRefusalReason is total, but none may change what the caller returns over HTTP: every one keeps today's generic 403 FORBIDDEN (hub/gateway/server.mjs:3116-3120; hub/server.mjs:486-490).

Two trigger descriptions are corrected here (N5). Round 1 paraphrased the live guards inaccurately while also mandating that isPersonalSelfApplyClass be re-implemented as personalSelfApplyRefusalReason(opts) === null. A literal build of the round-1 text would therefore have changed live behavior. The column below is the guard as written in source, and the build must reproduce it exactly:

Code Exact live guard Line
NOT_VAULT_WRITE !hasVaultWrite :108
NOT_PARTITION_OWNED !partitionOwned :108
ROLE_NOT_ELIGIBLE opts.role != null && !roleEligibleForPersonalSelfApply(opts.role, {humanActor, tokenType, actorKind})the role != null guard is load-bearing: when role is omitted the check is skipped entirely today :109-118
PROPOSAL_MISSING !proposal \|\| typeof proposal !== 'object' :119
STATUS_NOT_PROPOSED String(proposal.status ?? 'proposed').trim() !== 'proposed'an absent status is treated as proposed today; it must not become a refusal :120
FINGERPRINT_MISMATCH !matchesScoolingReviewTrayFingerprint(proposal) — the ordinary case for almost every proposal :121
ELEVATED_OR_AUTO_FLAGGED isElevatedOrAutoFlagged(proposal) :122

Add personalSelfApplyRefusalReason(opts) returning exactly one code, or null when the class holds. isPersonalSelfApplyClass keeps its boolean signature so existing callers are untouched; it must be implemented as personalSelfApplyRefusalReason(opts) === null so the two can never diverge. The function must be total — every path returns a code or null, and a unit test must assert that no input produces undefined.

S6.2 — How refusal codes reach HTTP (V5) — personalSelfApplyAllowsApprove

Both approve gates call personalSelfApplyAllowsApprove today — not isPersonalSelfApplyClass directly (hub/gateway/server.mjs:3103, hub/server.mjs:476; definition lib/hub-proposal-personal-self-apply.mjs:171-173). That wrapper is a boolean and cannot carry a code. The build freezes this traversal — Auto must not invent a parallel path:

  1. Keep personalSelfApplyAllowsApprove(opts) as personalSelfApplyRefusalReason(opts) === null (same boolean contract for any other caller).
  2. At both call sites, replace the bare boolean branch with:
    • const reason = personalSelfApplyRefusalReason(optsWithS2Inputs);
    • if reason === null → allow (same as today's personalSelfApplyAllowsApprove === true)
    • if reason is one of the HTTP-visible seam codes in the S6 table → HTTP 403 with { error: <stable message>, code: reason } — never collapsed to FORBIDDEN
    • otherwise (non-seam codes and SELF_APPLY_SUBJECT_INELIGIBLE) → today's generic 403 FORBIDDEN body at hub/gateway/server.mjs:3116-3120 and hub/server.mjs:486-490
  3. No third wrapper, no bypass of personalSelfApplyRefusalReason, and no call-site that returns a seam code without going through that function.

S6.1 — Refusal precedence is frozen (N6)

Exactly one code is returned, so the order decides which one. The order below is frozen. It is derived from two constraints, not from preference: (a) the non-seam guards must fire in their current source order so reason === null is bit-identical to today's boolean for non-overlap inputs, and (b) seam evaluation must come after PROPOSAL_MISSING, because every seam predicate dereferences the proposal.

# Check Code Why here
1 S10 ineligible subject SELF_APPLY_SUBJECT_INELIGIBLE Operator declaration outranks everything; internal-only, so it leaks nothing
2 !hasVaultWrite NOT_VAULT_WRITE Live order :108
3 !partitionOwned NOT_PARTITION_OWNED Live order :108
4 role ineligible (with role != null guard) ROLE_NOT_ELIGIBLE Live order :109-118; keeps the SEC-KN-3 agent gate ahead of any seam disclosure
5 proposal not an object PROPOSAL_MISSING Live order :119; seam predicates need a proposal
6 status not proposed STATUS_NOT_PROPOSED Live order :120
7 isDelegationSurfaceProposal SELF_APPLY_DELEGATION_REFUSED S2.5 — unconditional, before any authorship logic (V9)
8 seam ∧ sessionBound !== true SELF_APPLY_SESSION_BINDING_REQUIRED S2.2 first clause
9 seam ∧ author empty SELF_APPLY_AUTHOR_UNVERIFIED S2.2; every hosted proposal until T1
10 seam ∧ approver empty ∨ author ≠ approver SELF_APPLY_AUTHOR_MISMATCH S2.2
11 seam ∧ not admitted at T5 SELF_APPLY_NOT_ADMITTED Unconditional for every seam proposal in this phase — admission set is frozen empty (W3); T5's widening freeze is the only thing that may populate it
12 fingerprint mismatch FINGERPRINT_MISMATCH Live order :121
13 elevated / auto-flagged ELEVATED_OR_AUTO_FLAGGED Live order :122
14 null Class holds

Disclosure consequence, stated explicitly. Steps 2–6 run before any seam code, so a caller who fails an ordinary precondition learns nothing about seam classification; and steps 7–11 are reachable only by a caller who already holds vault:write on a partition-owned proposal with an eligible role. The HTTP-visible seam codes therefore disclose seam status only to an actor who could already read the proposal.

Live behavior — honest notes unchanged; overlap flips fail-closed (V3). Round 3's claim that "no seam proposal can match the review-tray fingerprint" is withdrawn — disproved by execution (§2.1, §10). The frozen consequences:

Input class Today Under S6.1 Notes
Honest notes tray (fingerprint, no S3.1 marker) eligible when ordinary guards pass unchanged — never enters steps 7–11 S2.4 / §9 R3 stand
Honest seam (S3.1 marker, no fingerprint) refuses at fingerprint (false) refuses at steps 7–11 (false, named code) boolean preserved; name changes
Overlap (fingerprint ∧ S3.1 marker) eligible (true) refuses at steps 7–11 (false) boolean flips, fail-closed; intentional under D4 = A

A test must assert the first two rows' boolean equivalence and the third row's flip. The flip is not a notes-tray regression: an honest notes row does not carry seam apply markers.

S7 — The hosted media surface gap is recorded, not silently "supported"

No hosted media proposal route exists (G17); Scooling's hosted media transport targets https://api.knowtation.store/api/v1/attachments/* (G21, G25), which falls through the gateway catch-all to the canister and returns 404 NOT_FOUND (G18).

Therefore:

  1. SEC-SEAM-1b must not build a hosted media proposal route. That is a new surface, not a P3 fix.
  2. docs/PROPOSAL-LIFECYCLE.md must record that media proposals are self-hosted-only today and that the hosted media transport is unreachable.
  3. A test must assert the gateway exposes no api/v1/attachments/* route, so a later phase cannot add one without deliberately updating this freeze.
  4. Media is excluded from the "accept session-bound learner identity" deliverable for the hosted path, because there is no hosted path to accept it on. Self-hosted media is still classified seam (S3.1 condition #5), so media proposals that can actually reach an apply today are covered.
  5. §12 D2 is ratified as option A (§12.1): media stays out of scope and a roadmap row is opened for a hosted media proposal surface. SEC-SEAM-1b ships without it.
  6. When a hosted media route is later built, it must ship with a maybeApplyHostedMediaAfterApprove hook and a matching S3.1 condition added in the same change — the S3.0 anti-drift rule applies to future surfaces, not only to the surfaces that exist today. Recording this here is what makes G30's "no hosted media apply hook" a stated gap rather than a silent hole in the correspondence table.

S8 — The consumer contract is published

Record in docs/PROPOSAL-LIFECYCLE.md (and cross-link from ~/scooling/docs/ADAPTER-CONTRACTS.md under the Task and Delegation adapter sections, which today say nothing about which credential is used — G-note in §7):

Consumers must present a per-learner, session-bound Knowtation credential on task, media, and delegation proposal-create surfaces. A shared service credential is a contract violation. Knowtation cannot detect it (§3.2); its only server-side consequence is permanent ineligibility for personal self-apply.

S10 — Operator-declared self-apply-ineligible subjects

Freeze review round 1 (F5) identified a server-side control this freeze had missed, and it is adopted. Knowtation cannot detect a shared credential (§3.2), but the operator knows which account backs the shared token — it is their own. Naming that sub server-side is a declaration, not a heuristic.

  1. Add HUB_SELF_APPLY_INELIGIBLE_SUBS, a comma-separated env list of sub values permanently ineligible for personal self-apply — compared exactly, never pattern-matched. Parse shape copies hub/gateway/server.mjs:156-160 (HUB_ADMIN_USER_IDS.split(',').map(trim).filter(Boolean)Set), but the implementation must not live in hub/gateway/** (V10): put a pure parseSelfApplyIneligibleSubs(raw) in lib/hub-self-apply-ineligible.mjs (or an equivalent lib/** module that does not import hub/**). Production wires process.env.HUB_SELF_APPLY_INELIGIBLE_SUBS through that parser once at module load into a module-level Set. Tier 7b exercises unset / '' / ',, ' by calling the pure parser directly with those strings in one run — not by mutating process.env and hoping module load re-runs (V6). Round 1 cited hub/gateway/access-token-authz.mjs:75-77 and :55-66 for this; both citations were wrong (N8).
  2. When the approving actor's sub is on the list, personalSelfApplyRefusalReason returns SELF_APPLY_SUBJECT_INELIGIBLE. It is step 1 of the frozen precedence (S6.1) and applies to seam and non-seam proposals alike. It is internal-only — the HTTP response is unchanged (S6, N7).
  3. Absent or empty env means no subject is listed. This is the one place where absence is permissive, because the alternative — an unset variable disabling all self-apply — would take the live notes review tray down (§9 R3, and the same live-path reasoning as S2.4). The empty-env branch is the one S10 branch whose failure is a live outage, so §7 requires a dedicated test for it (N13).
  4. Setting the variable in production is an operator action, i.e. Tier 3 (gate T3). Shipping the code is not.

S10.4 — What S10 does and does not deliver (N14, ratified D3)

Ships dormant. Per ratified D3 the list is empty in SEC-SEAM-1b. S10 bars nobody in this phase; it makes the control available for a later Tier-3 action. Any statement that S10 "closes" the shared-identity case must carry this qualification — an earlier draft of the paragraph below did not.

It cannot be used without cost. The shared consumer token is a JWT for a human operator account. A machine-credential mint path does exist (signServiceJwt, G35) but is not what backs those env tokens today, and it is already self-apply-ineligible under S1 (legacy_session). Listing the operator's human sub still disables that human's own legitimate self-apply from their own browser, because §3.2's indistinguishability applies to S10 itself — the server cannot tell the operator's shared-token use from the operator's personal use of the same sub. S10 is therefore a control the operator trades something for, not a free win. (V1: the "no machine-credential mint path" premise is withdrawn; the cost argument for listing the human sub stands.)

What it would deliver if populated: the single-shared-identity case that S1 and S2 cannot reach, closed before the Scooling work (C1–C4) lands, narrowing the window §9 R4 accepts as open. It is the closest available thing to the driving finding's literal wording (§3.4).

What it does not do: it needs the operator to know and maintain the list, it costs that operator their own self-apply, and it does nothing for a shared credential the operator has not declared. It is a complement to C1–C4, not a substitute.

S9 — No scope creep (closing rule — deliberately last, after S10)

SEC-SEAM-1b changes only:

File Change
hub/gateway/server.mjs:213-228, :277-287 S1 mint claim (D1 = A, ratified). :1282 is deliberately not stamped (G3)
hub/server.mjs:308-316, :326-332 S1 mint claim at both self-hosted sites — login and refresh (N9)
hub/lib/local-auth.mjs:179-192 S1 mint claim at issueLocalToken (V2 / G36)
hub/gateway/server.mjs:156-160 Reference pattern only for S10.1 — not modified
hub/gateway/access-token-authz.mjs S1 resolveActorTokenClass / isSessionBoundActor only — not the S10 set (V10). Self-hosted may import these two exports (W2)
lib/hub-self-apply-ineligible.mjs new — S10 pure parser + module-load Set (V6, V10)
lib/hub-proposal-personal-self-apply.mjs S2, S3, S6, S10 consult — imports apply-path predicates + flow source constants (S3.1) and the S10 lib/ module; no hub/gateway/** import
hub/gateway/server.mjs:2949, :2969, :3025, :3077, :3102-3111, :3116-3120 S2.1 hosted inputs — extend resolveHostedActorRole to return the payload at both :2969 and :3025 (N17, W1); S6.2 seam-code HTTP branch at :3116-3120 (V4, V5)
hub/server.mjs:467-491, :476, :486-490 S2.1 self-hosted inputs (incl. authorized import of access-token-authz.mjs — W2); S6.2 seam-code HTTP branch (V4, V5)
hub/gateway/cors-middleware.mjs:60, hub/bridge/server.mjs:872 S4.2
docs/PROPOSAL-LIFECYCLE.md S7.2, S8
docs/ROADMAP.md:49 Status refresh for SEC-SEAM-1 (round-4 / D5 = A / reviewer verdict) plus the new roadmap row for a hosted media proposal surface (S7.5, ratified D2 = A). The §3.4 disclaimer on line 49 already exists — do not re-argue it (W4)
~/scooling/docs/ADAPTER-CONTRACTS.md S8 cross-link. Owned by Scooling as C6, not built by SEC-SEAM-1b — listed here so this table is not contradicted by S8 (N12)
test/sec-seam-1-session-bound-identity.test.mjs new

Nothing else. No new seam field list, in any file (S3.0). Conditions #6/#7 test the same proposal.source === … expressions the apply path already uses (G32) — that is S3.0-compliant, not a parallel list.

Do not in this phase: add a hosted media route; change MEDIA_WRITE_ROLES; touch the delegation apply path (SEC-KN-4 owns it); add telemetry for shared-credential detection; widen the self-apply fingerprint — widening is gate T5; stamp signServiceJwt (G35); or import hub/gateway/** from lib/hub-proposal-personal-self-apply.mjs.

4.1 Frozen signatures

// hub/gateway/access-token-authz.mjs
export function resolveActorTokenClass(payload):
  'session' | 'mcp_access' | 'legacy_session' | 'unknown'
export function isSessionBoundActor(payload): boolean
// isSessionBoundActor(null) === false  (V11)

// lib/hub-self-apply-ineligible.mjs  (V6, V10)
export function parseSelfApplyIneligibleSubs(raw: string | undefined | null): Set<string>
// module-load: parseSelfApplyIneligibleSubs(process.env.HUB_SELF_APPLY_INELIGIBLE_SUBS)

// lib/hub-proposal-personal-self-apply.mjs
// NOTE: no SEAM_SURFACE_INTENTS — deleted by S3 (D4 = A). No seam field list may exist (S3.0).
export function isSeamSurfaceProposal(proposal): boolean
export function isDelegationSurfaceProposal(proposal): boolean
export function personalSelfApplyRefusalReason({
  proposal, hasVaultWrite, partitionOwned, role,
  humanActor, tokenType, actorKind,
  authorActorId, approverActorId, sessionBound,
}): string | null
export function personalSelfApplyAllowsApprove(opts): boolean
// === personalSelfApplyRefusalReason(opts) === null  (V5)

5. Scope

In scope (Knowtation, SEC-SEAM-1b): S1–S10 exactly as written.

In scope (Scooling, L-SEAM — a separate build on the Scooling board): C1–C6 in §6.

Out of scope: hosted media route (S7); media role posture (§12 D2); author-binding the existing notes self-apply path (§9 R3); any change to delegation apply (SEC-KN-4); the actual widening of the self-apply class (gate T5, FINISH-COMPLETE-APPLY-KN-b); secret rotation (Pass 2 P7, already done); applyPersonalSelfApplyEvaluationE1 (§9 R8).


6. Consumer contract — Scooling L-SEAM (not built by SEC-SEAM-1b)

Frozen here so both boards agree; built and verified on the Scooling board.

# Requirement Anchor
C1 Hosted task, media, and delegation write transports stop sourcing Authorization from KNOWTATION_AUTH_TOKEN / KNOWTATION_HUB_TOKEN taskWriteHubTransport.ts:208-212, :293-302; mediaWriteHubTransport.ts:112-115, :314-322; delegationHubTransport.ts:486-489, :344-355
C2 They take the per-request learner token from the existing session mechanism — resolveHostedAuthSessionContext(...).rawToken — mirroring the notes path and the calendar session substitution scoolingHostedAuth.ts:373-417; hostedReviewWriteBack.ts:843-851; calendarHubTransport.ts:407-427
C3 The synthetic task-write UI actor is replaced by the real session; the routes must pass request through to the surface taskWriteSurface.ts:65-68, :222-272; app/routes/tasks.tsx:23-27 and the three sibling routes
C4 When no learner session is present, the write refuses with a distinct reason. It must not fall back to the env token — a fallback reproduces P3 exactly new refusal code, sibling of hostedReviewWriteBack refusals
C5 Learner identity is never sent as a header or body field; scooling_uid stays internal to Scooling identityAdapter.ts:242-251; enforced Knowtation-side by S4
C6 The env token may remain only for operator/maintenance paths that are not learner-attributed, and ADAPTER-CONTRACTS.md must state which those are ~/scooling/docs/ADAPTER-CONTRACTS.md:470-491, :511-531

Ownership split in one line: Scooling makes each request carry the right learner; Knowtation makes the wrong learner un-self-appliable and says so by name. Neither half is sufficient alone, and the Scooling half is the only one that fixes partition and attribution (§2.2).

Sequencing: C1–C4 may ship before or after SEC-SEAM-1b — they are independent, because S1–S10 change no behavior that today's Scooling depends on. Both must be done before gate T5.


7. Test matrix — seven tiers plus security regression

One new file, test/sec-seam-1-session-bound-identity.test.mjs, following the established SEC-KN layout (tier-banner describe blocks; a pre-fix replica in the security tier; source-read assertions where behavior is structural).

Tier Must prove
1 unit resolveActorTokenClass returns each of the four classes for the four payload shapes, including null, and classifies the :1282 internal-hop payload ({sub} only) and a signServiceJwt-shaped {sub, role:'service'} payload as legacy_session (G3, G35); isSessionBoundActor is true only for 'session' and is false for null (V11); isSeamSurfaceProposal returns true for each of the seven S3.1 conditions independently and false for a notes review-tray proposal; isDelegationSurfaceProposal is true for conditions 2 and 4 only; a predicate that throws classifies seam (fail-closed, S3.1); personalSelfApplyRefusalReason is total — no input yields undefined; parseSelfApplyIneligibleSubs returns an empty Set for unset / '' / ',, ' (V6)
2 integration personalSelfApplyRefusalReason returns each S6 code — seam and non-seam — for its exact trigger, in the S6.1 precedence order (assert the order directly: an input satisfying several refusals returns the earliest); isPersonalSelfApplyClass / personalSelfApplyAllowsApprove equal reason === null across the matrix (V5); the non-seam codes and SELF_APPLY_SUBJECT_INELIGIBLE do not change the HTTP response (still generic 403 FORBIDDEN at hub/gateway/server.mjs:3116-3120 and hub/server.mjs:486-490 — N7); HTTP-visible seam codes are returned at those same sites (V4); both call sites pass the three new inputs — hub/gateway/server.mjs:3102-3111 and hub/server.mjs:476 (source-read assertions); S1 stamps issueLocalToken (G36)
2b behavior preservation (N5) A differential test over a generated input matrix: for every combination, personalSelfApplyRefusalReason(opts) === null equals the pre-fix isPersonalSelfApplyClass boolean whenever no seam/S10 input is supplied. Must specifically cover the two guards round 1 misstated — role omitted (check skipped, still eligible) and status absent (treated as 'proposed', still eligible) — so a regression here fails loudly rather than silently tightening a live path
3 e2e Approve-eligibility matrix over {seam, non-seam} × {session-bound, legacy, mcp_access} × {author==approver, author≠approver, author empty}; the notes review-tray fingerprint with a legacy token and empty author still resolves eligible (no live regression — §4 S2.4)
3b role floor (N13) S5: a member actor on hosted task propose and a viewer actor on self-hosted task and delegation-consent propose all still succeed (G15, G16) — the consumer's switch to learner tokens must not start returning 403
4 stress Many distinct seam proposals sharing one author id never produce an eligible result; large/adversarial intent strings and a 128-char author id do not bypass the trim/equality check
5 data-integrity Refusal is a pure decision — no proposal field is mutated, no evaluation field is written, and the result is idempotent across repeated calls with the same inputs
6 performance Eligibility resolution adds no filesystem or network read beyond today's single fetchHostedProposalForSelfApply call
7 security Regression that fails against pre-fix code. A pre-fix replica of isPersonalSelfApplyClass (today's :106-124) must return true for a task_create proposal admitted to the fingerprint class under a single shared identity; the fixed function must return false with SELF_APPLY_SESSION_BINDING_REQUIRED or SELF_APPLY_AUTHOR_MISMATCH. The N1 evasion, as the primary case: a proposal that omits proposal_kind and sets knowtation_proposal_source + task_proposal_kind must classify seam — and the test must assert the round-1 rule (frontmatter.proposal_kind ∈ list) would have classified it non-seam, so the differential is demonstrated rather than asserted. Also: intent omitted, and intent renamed to an unlisted value, must not change the classification either way. V3 overlap: fingerprint ∧ task markers → pre-fix replica true, fixed function false with a seam code (not FINGERPRINT_MISMATCH). Correspondence (S3.1): for each of the seven conditions, a proposal that satisfies the approve-time apply trigger satisfies isSeamSurfaceProposal — asserted by calling the same functions/expressions the hooks use (normalizeCanisterProposalForTaskPrecheck, isDelegationProposalIntent, and the five *_PROPOSAL_SOURCE constants including flow / flow_capture), so the test breaks if the hook's dispatch changes. S3.0 source-read: the shipped lib/hub-proposal-personal-self-apply.mjs contains no literal seam kind/intent list — assert the absence of the deleted SEAM_SURFACE_INTENTS identifier and of the string task_proposal_kind; assert it does not import from hub/gateway/** (V10). S10: an approver whose sub is on the ineligible set is refused even when every other condition holds, including on the notes fingerprint, and the HTTP body stays generic FORBIDDEN (N7). Plus: no route reads a client X-User-Id/X-Actor-Id (source-read over hub/bridge/** and hub/gateway/server.mjs); X-User-Id is absent from the allow-headers of both hub/gateway/cors-middleware.mjs and hub/bridge/server.mjs; the gateway exposes no api/v1/attachments/* route (S7.3)
7b S10 empty-env (N13, V6) Call parseSelfApplyIneligibleSubs with undefined, '', and ',, ' in one run — each yields an empty Set — and assert the notes review-tray fingerprint still resolves eligible when the ineligible set is empty. Do not require reloading the module under three process.env values. This is the one S10 branch whose failure is a live outage (S10.3), and the ratified D3 selection means it is the branch that actually ships

Test-honesty requirement (from SEC-KN-4b BV round 1): the security tier must demonstrate the discrimination — the pre-fix replica has to be a branch-for-branch copy of the current function, and the test must fail if the fix is reverted. Tautological assertions over two constants are not evidence.

Because honest seam proposes do not reach the class through the fingerprint (§2.1), the tier-3 and tier-7 cases must construct the widened-class and overlap scenarios explicitly (fingerprint satisfied and seam marker) so the admission gate and the V3 flip are exercised rather than masked by the fingerprint refusal.


8. Tier 3 gates (not part of SEC-SEAM-1b)

Gate Description
T1 Canister WASM upgrade installing created_by — inherited from SEC-KN-4. Until T1, every hosted authorActorId is "" and S2 refuses with SELF_APPLY_AUTHOR_UNVERIFIED
T2 Merge to Muse main, or a muse-mirror → main PR (SD-14)
T3 Flipping SCOOLING_TASK_WRITES / SCOOLING_MEDIA_* / SCOOLING_DELEGATION_* on in production
T4 Restoring the migration hook to identity after T1 — inherited (SEC-KN-4c)
T5 Admitting any intent into the personal self-apply class. Requires: S1–S10 done, C1–C4 done, T1 installed, and a fresh freeze for the widening itself

9. Residual risks and non-goals (explicitly accepted)

# Risk Disposition
R1 A learner's own token copied into operator env still passes every server-side check Accepted and stated (§3.2). Blast radius shrinks from all learners to one, which is ordinary credential compromise rather than systemic identity collapse
R2 S1's class claim does not prove liveness Accepted (§4 S1 rationale). Its value is barring a future machine credential by name and failing closed on unknown classes
R3 The existing notes self-apply path is not author-bound by this phase Deliberate. Requiring non-empty created_by there would break a live hosted path before T1 (§3.1(2)), and a partition-owner fallback is the SEC-KN-4 D1 fail-open the operator already rejected. Tracked as a post-T1 roadmap row, not silently dropped
R4 Until C1–C4 ship, partition and attribution stay collapsed (§2.2) Accepted. Not fixable in Knowtation — S10 would remove the self-apply consequence if the operator declared the subject, but per ratified D3 the list ships empty, so in this phase it removes nothing; partitioning and attribution stay wrong until the consumer sends per-learner credentials. The three surfaces are env-off in production, so nothing live degrades meanwhile
R5 Media hosted remains unreachable Accepted (S7). Recorded rather than papered over
R6 legacy_session tokens remain valid until natural expiry Accepted. They are propose-capable and self-apply-ineligible, which is the fail-closed direction
R7 The role collapse in §2.2 (learners inheriting the operator's allowlist role) is not addressed server-side Accepted; it is a direct consequence of the shared credential and resolves entirely with C1–C4
R8 applyPersonalSelfApplyEvaluationE1 (lib/hub-proposal-personal-self-apply.mjs:134-164) is a second consumer of the self-apply concept in the same file. It stamps evaluation_status: 'passed' at create time gated only by matchesScoolingReviewTrayFingerprint, wired at lib/hub-proposal-create-augment.mjs:15 and hub/proposals-store.mjs:12. When gate T5 widens the fingerprint, E1 widens with it — unbound by S2 Out of scope here (§5), because touching E1 changes create-time behavior on the live notes path. Must be author-bound as part of the T5 widening freeze, which cannot be signed off without it. Raised by freeze review round 1 (F14)
R9 S10's ineligible list is operator-maintained, ships empty (D3), and cannot be populated without collateral cost Accepted, with the N14 qualification and the V1 correction. A subject the operator has not declared gets no protection from S10, and in SEC-SEAM-1b that is every subject. Populating it is Tier 3 (T3). The shared consumer token belongs to a human operator account; listing that sub also disables that human's own self-apply from their browser. A machine-credential mint path exists (signServiceJwt, G35) but is already legacy_session under S1 and is not what backs those env tokens today. S10 is a dormant, cost-bearing control in this phase; S1/S2 carry the enforcement that actually ships. See S10.4
R10 Seam classification reuses enabling apply-path predicates as restricting classifiers Accepted, and preferred to a parallel list. A change that makes normalizeCanisterProposalForTaskPrecheck (or a sibling) more permissive — in order to apply more rows — also pulls more proposals into the seam refusal branch, including fingerprint-overlap rows (the V3 mechanism). That co-movement is the point of S3.0 for apply triggers; the residual is that review-tray rows which also grow seam markers flip from eligible to refused (fail-closed, §2.1 / S6.1). The tier-7 correspondence + overlap tests convert a future divergence or silent tray break into a failing build

10. Ground-truth edge — what this session could not verify

Item Status
The 404 for hosted media proposals Code-verified, not live-probed — derived from the gateway routing table (G17) plus the canister default (G18). No HTTP request was made in this session
Whether the MCP host shares SESSION_SECRET with the gateway UNVERIFIED, unchanged from the handover snapshot. Does not affect S1–S10
The exact production value/holder of KNOWTATION_AUTH_TOKEN Not inspected by design. No secret was read or printed
Self-hosted mint site line numbers for S1.1 RESOLVED in round 3 (N9). Both sites were read directly: hub/server.mjs:308-316 (issueToken, login) and :326-332 (issueAccessTokenForSub, refresh). The round-1 deferral of this to Auto is withdrawn; there are two sites, not "the self-hosted equivalent", and missing the refresh one would break seam self-apply after any token refresh
Whether the notes review tray is genuinely live (N15) Document-verified, not live-probed. The driving audit records SCOOLING_HOSTED_REVIEW_WRITE_BACK as absent (~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md:86), which would make the "would break the live notes review tray" justification for S2.4 / §9 R3 / S10.3 hollow. That audit row is stale: Scooling records P8 as resolved on 2026-07-26 with SCOOLING_HOSTED_REVIEW_WRITE_BACK=enabled on production (~/scooling/docs/OVERSEER-HANDOVER.md:95, :130, :299, :323; ~/scooling/docs/ROADMAP.md:122, :162). The deferrals therefore stand. Caveat kept: Scooling's own note says the post-env live Approve smoke was not re-run, so "live" here means operator-set env recorded in two governance docs, not an observed 200
Predicate reuse under S3.1 Execution-verified in round 3. node importing lib/task/task-hosted-proposal.mjs, lib/agent/delegation-hosted-proposal.mjs, lib/attachments/attachment-write.mjs, lib/task/task-write.mjs, and lib/hub-proposal-personal-self-apply.mjs together succeeds (no cycle); the N1 crafted proposal returns non-null from normalizeCanisterProposalForTaskPrecheck (seam) while its frontmatter.proposal_kind is undefined (round-1 rule: non-seam); and normalizeCanisterProposalForDelegationPrecheck returns non-null wherever isDelegationProposalIntent is true (G28 superset)
V3 fingerprint ∧ seam overlap Execution-verified in round 4. A proposal with review-tray fingerprint fields and knowtation_proposal_source + task_proposal_kind (string or object frontmatter) yields normalizeCanisterProposalForTaskPrecheck != null, matchesScoolingReviewTrayFingerprint === true, and isPersonalSelfApplyClass === true today — so S6.1's absolute "no live behavior change" claim was false
Flow forgeability (D5 ground) Execution-verified in round 4. source: 'flow' + forged fingerprint → self-apply class true; honest flow path → false. Apply dispatch and shared-token citations: G32–G34
V1 / V2 / V4–V11 citations Re-derived from source in round 4 before amendment. V2 mount sites corrected to registerLocalAuthRoutes call sites (:510 / :625), not the import lines round 3 cited
Scooling mediaWriteHubTransport.ts / delegationHubTransport.ts line numbers (G21) Originally spot-checked by pattern rather than read line-by-line. Freeze review round 1 read them directly and confirmed both, and added the nuance now recorded in G21: delegation reads KNOWTATION_AUTH_TOKEN with no KNOWTATION_HUB_TOKEN fallback

Two claims in the first draft were presented as verified ground truth and were not — both caught by freeze review round 1, both now corrected in §2 rather than merely disclosed here:

  • G3 claimed "exactly two" gateway mint sites. There are three (hub/gateway/server.mjs:1282). The omission was fail-closed — the third classifies as legacy_session — but S1.1 told the build the enumeration was exhaustive (F2).
  • S3 keyed seam classification on task_meta.proposal_kind, which does not exist on the canister record at all (G19.2), making the hosted classifier inert and leaving only a client-supplied intent (F3). This was the most consequential defect in the draft and is why S3.1/S3.2 now exist.

Round 2 found that round 1's own repair was also wrong, and round 3 records that plainly. The F3 fix re-keyed classification onto frontmatter.proposal_kind — a second hand-written field list, and still not the key the apply hook reads (task_proposal_kind, G27). Two consecutive rounds wrote a parallel list and two consecutive lists were wrong in the same way, which is the evidence behind S3.0's blanket prohibition: the defect was never the choice of key, it was the existence of a parallel key at all.

Round-1's "all verified against source" claim was overstated (N16), and is corrected here. N3 and N11 both found errors inside rows that claim had covered — ten of twelve SEAM_SURFACE_INTENTS entries were not intents, and the mcp_access enumeration missed two refresh sites. The claim in §11's round-1 row is amended to "every finding was checked against source before amendment", which is what actually happened, rather than "every resulting row is correct", which round 2 disproved.


11. Review record

Round Reviewer Verdict Resolution
1 Freeze-review loop (thinking, independent) blocked 14 findings, all cited. Each finding was checked against source before amendment — no finding was accepted on the reviewer's word. (N16 correction: the original wording, "all verified against source", implied the resulting rows were correct; round 2 disproved that via N3 and N11, so the weaker and accurate claim is recorded instead.) F1 stamp removed (§ header note). F2 G3 corrected to three mint sites + G3.1. F3 S3.1/S3.2 rewritten — task_meta is not on the canister record; classification is now a fail-closed union over intent and server-set frontmatter.proposal_kind, with obligations attached to allowlist admission rather than a skippable overlay. F4 seven non-seam refusal codes added, marked internal-only. F5 adopted as new rule S10 (operator-declared ineligible subjects) and §3.2's "only" corrected. F6 S4.2 extended to hub/gateway/cors-middleware.mjs:60 with a stated disposition for main.mo:221. F7 self-hosted call site named (hub/server.mjs:467-491). F8 S2.1 input-source table added. F9 G19 re-scoped and enumeration completed. F10 kit-root path prefix. F11 line cites corrected to the frontmatter sources. F12 §3.4 deviation subsection added. F13 D1 third option added. F14 E1 recorded as §9 R8 + §5 out-of-scope
3 Freeze-review loop (thinking-high, independent, fresh — no authoring context) blocked 11 findings (V1–V11); 1 escalating: V1 (security). Confirms the D4 = A rewrite works — N1's evasion is closed by construction, reproduced by execution — and independently confirms 15 of 18 round-2 findings resolved. But it disproves three claims round 3 asserted: V1 the "no machine-credential mint path" premise (one exists), V2 S1's "all four mint sites" (a fifth exists), V3 S6.1's "no live behavior change" (disproved by execution). Loop stopped for the operator per .claude/skills/freeze-review-loop/SKILL.md:28-39security category and blocked verdict are both hard stops. No finding auto-fixed
2 Freeze-review loop (thinking, independent, fresh) blocked 18 findings (N1–N18). Round-1 fixes verified from source: F1, F6, F7, F9, F10, F11, F12, F14 HOLD; F2, F4, F5, F8 PARTIAL; F3 and F13 DO NOT HOLD. Two escalating: N1 (security, BLOCKER) and N14 (security, MINOR); N2 is a BLOCKER spec gap introduced by round 1. Loop stopped for the operator per .cursor/skills/freeze-review-loop/SKILL.md:28-36. No finding auto-fixed in this round.
4 Fix round (Thinking) — operator D5 = A; citations re-derived; V3 overlap re-executed findings (5 MINOR, none escalating) Independent reviewer (thinking-high, fresh — round-4 review) re-derived V1–V11 + D5 from source; confirmed all hold, including V3 by execution. Returned W1–W5 (all MINOR). No operator decision. Fixer addresses W1–W5 in place
5 Fix round (Thinking) — W1–W5 only findings (2 MINOR: X1, X2) Independent reviewer (round-5 review) — W1–W5 HOLD; V1–V11 / D5 / S3.0 spot-checks hold; no stamp in YAML. X1: S2.1 "live effect" wording contradicted W3. X2: header stale vs round-5 row
6 Fix round (Thinking) — X1–X2 only findings (1 MINOR: Y1) Independent reviewer (round-6 review) — X1/X2 HOLD at cited loci; YAML stamp absent; W3/S3.0/D1–D5 clean. Y1: S1.1 N9 rationale still claimed "live effect before T1"
7 Fix round (Thinking) — Y1 only pass Independent reviewer (round-7 clearance) — Y1 HOLD; X1/X2/W3 consistent; no YAML stamp at review time; D1–D5 ratified; T1–T5 unauthorized; S3.0 intact. FINDINGS: none. Mechanical gate re-run after recording this pass; stamp retained only because §11 now has semantic pass (F1 rule)

Round-1 escalations (F1, F3, F5, F6) were technical or procedural defects, not operator decisions, and were fixed in place. Round 2 is different: N1 changes where enforcement must live, which is a design decision, and round 1's own fix for F3 is what N1 refutes. The round-2 session did not attempt a third self-directed redesign inside the loop.

Round 3 (this fix round) started by obtaining the operator's selection, not by assuming it. The paste-ready prompt it was launched with contained the words "fix N1 per D4 option A" — but that text was written by the round-2 session itself (docs/OVERSEER-HANDOVER.md:46-60), so treating it as ratification would have been an authoring session clearing its own escalation, the exact defect that reverted SEC-KN-4a §12.1 in that phase's round 2. The session stopped, asked, and recorded the operator's four answers verbatim in §12.1 before editing any rule.

11.1 Round-2 findings — full list, all addressed in round 3

Every citation below was re-verified against source by the round-3 session; none is accepted on the reviewer's word, and two reviewer citations were themselves corrected (see N15 and N18). N1's evasion and its fix were reproduced by executing the predicates, not only by reading them (§10).

Disposition summary. All 18 addressed: 16 fixed as cited, N15 resolved as the audit row is stale (the reverse of what the finding assumed), N18 fixed with a corrected line number. The two BLOCKERs are closed by the ratified decisions: N1 by D4 = A (S3 rewritten around the apply path's own predicate), N2 by D1 = A (S1 is now unconditional, so no C-selection spec gap exists).

# Disposition Where
N1 Fixed — S3 rewritten; classification calls the apply path's predicate; anti-drift rule S3.0 frozen; evasion + fix reproduced by execution S3.0–S3.3, G27–G31, §10
N2 Fixed — D1 ratified A, so S1 is unconditional; the option-B/C clause and its S1.4 mis-description are deleted S1 preamble
N3 DissolvedSEAM_SURFACE_INTENTS deleted entirely rather than re-keyed S3.0
N4 Fixed — S2.2 now triggers on isSeamSurfaceProposal, with the reason stated S2.2
N5 Fixed — S6 trigger table replaced with the exact live guards, incl. role != null and status ?? 'proposed'; tier-2b differential test added S6, §7
N6 Fixed — full 14-step precedence frozen, derived from live order + predicate needs S6.1
N7 FixedSELF_APPLY_SUBJECT_INELIGIBLE demoted to internal-only, removing both the contradiction and the disclosure S6, S10.2
N8 Fixed — citation corrected to hub/gateway/server.mjs:156-160; the two wrong cites named S10.1
N9 Fixed — both self-hosted mint sites enumerated; §10 deferral withdrawn S1.1, S9, §10
N10 Dissolved — S3.1 reads source self-hosted and reuses the dual-shape parser S3.3, G31
N11 Fixed — four mcp_access mint sites, grant + refresh in each provider G3.1
N12 FixedADAPTER-CONTRACTS.md added to the S9 table, marked Scooling-owned (C6) S9
N13 Fixed — tier 3b (S5 role floor) and tier 7b (S10 empty-env) rows added §7
N14 Fixed — S10.4 added; §3.2, §3.4, R4, R9 all qualified; D3 warning delivered before ratification S10.4, §3.2, §3.4, §9
N15 Resolved, finding inverted — the audit row is stale, not the justification: Scooling records P8 done 2026-07-26. Tension now disclosed, with the "no live smoke" caveat kept §10
N16 Fixed — header note corrected; round-1 row's "all verified" claim amended header, §10, §11
N17 FixedresolveHostedActorRole cited at :2949 with the :2969 early return called out S2.1, S9
N18 Fixed, with a correction to the finding — the proposals-POST frontmatter extraction is :1371, not :1372 (:1372 is base_state_id); :1361 correctly identified as the notes branch and dropped G19.4
# Sev Category Citation Finding
N1 BLOCKER security hub/gateway/task-approve-hosted.mjs:11, :17-19, :36-38, :56; lib/task/task-hosted-proposal.mjs:18-23, :60-77, :85-156; hub/gateway/server.mjs:3386 Parser differential — the round-1 F3 fix does not close the hole. Seam classification (S3.1) reads frontmatter.proposal_kind; the task apply hook dispatches on frontmatter.knowtation_proposal_source / proposal.source / a meta/tasks/proposals/ path prefix, and takes the kind from frontmatter.task_proposal_kindtask_meta.proposal_kindJSON.parse(proposal.body).proposal_kind. task_proposal_kind and proposal_kind are different keys (:20 vs lib/task/task-write.mjs:494). Honest hosted proposals carry both (mergeTaskFrontmatter:60-77 merges the FM_* keys over the base frontmatter), so S3.1 works on honest traffic and fails exactly on crafted traffic: a proposal that matches an admitted allowlist entry, omits proposal_kind, and sets knowtation_proposal_source + task_proposal_kind classifies non-seam while still firing the task apply. The hook runs on the same generic approve route self-apply authorizes (PROPOSAL_APPROVE_RE:11 + 2xx, invoked at hub/gateway/server.mjs:3386). Containment today is the bridge task-writes env gate, not this freeze
N2 BLOCKER completeness / gates_tier3 artifact S1 conditional vs §12 D1 option C and the S9 "only if D1 = A" row Round 1 added D1 option C without a matching rule. A C selection leaves Auto with no spec: build S1.2/S1.3 without the mint claim (every seam self-apply permanently refused) or drop S1 as under B (legacy_session counts as bound)? Those differ by a whole security property. S1's option-B clause also mis-cites S1.4 as a "negative test" and contradicts S1.3
N3 MAJOR completeness lib/task/task-write.mjs:374, :494; lib/attachments/attachment-write.mjs:356, :499; lib/agent/delegation-hosted-proposal.mjs:20-24 Ten of twelve SEAM_SURFACE_INTENTS entries are proposal_kind values, not intents. Only the two delegation entries are real intents. The intent branch of the S3.1 union is therefore inert for task and media, and S3.2.2's "renaming the intent costs the attacker eligibility" argument is empty — a real task proposal's intent was never in the set. The set needs renaming and re-keying
N4 MAJOR consistency artifact S2.2 vs S3.1 S2.2 still says obligations attach "when the proposal's intent is in SEAM_SURFACE_INTENTS". Implemented literally that reinstates the F3 defect. S2.2 must call isSeamSurfaceProposal
N5 MAJOR completeness lib/hub-proposal-personal-self-apply.mjs:110, :120, :88-89 Two non-seam trigger descriptions misstate the live guard, and S6 mandates re-implementing the boolean as reason === null, so a literal build changes live behavior: the real role guard is opts.role != null && !roleEligible(...), and status is String(proposal.status ?? 'proposed') — an absent status is treated as proposed today
N6 MAJOR completeness artifact S2.3, S10.2, S6 table; lib/hub-proposal-personal-self-apply.mjs:108 Refusal precedence is only partly specified (S10 first, seam before fingerprint). Nothing orders seam evaluation against hasVaultWrite / partitionOwned / role / status. Since exactly one code is returned and seam codes are HTTP-visible while non-seam are not, ordering decides what leaks
N7 MAJOR consistency (security-adjacent) artifact S6 seam table vs S10.2 SELF_APPLY_SUBJECT_INELIGIBLE is listed HTTP-visible but S10.2 applies it to non-seam proposals too — contradicting S6's "none may change what the caller returns over HTTP", and disclosing blocklist membership
N8 MAJOR completeness artifact S10.1 and §3.2 vs hub/gateway/access-token-authz.mjs:75-77, :55-66, and hub/gateway/server.mjs:156-160 S10.1's "model it on the existing admin-allowlist pattern" cites the wrong functions: :75-77 is mayApplyAdminAllowlistOverride and :55-66 consults no allowlist. The env→Set pattern S10 wants is hub/gateway/server.mjs:156-160
N9 MAJOR completeness hub/server.mjs:308-316, :326-332 There are two self-hosted mint sites, not "the self-hosted equivalent", and §10 defers finding them to Auto. Stamping only login leaves refreshed tokens legacy_session, so seam self-apply silently starts refusing after a refresh — on the path S2.1 says has live effect before T1
N10 MAJOR completeness hub/proposals-store.mjs:212, :271-289 The self-hosted store persists frontmatter as an object and keeps task_meta.proposal_kind. S3.1 was written for the hosted string-shaped frontmatter (its parse-failure rule presumes a string), so the self-hosted input shape is unspecified and an available signal is discarded
N11 MINOR completeness hub/gateway/device-oauth-provider.mjs:199, :257; hub/gateway/mcp-oauth-provider.mjs:235, :306 G3.1 says mcp_access is minted at two further sites; there are four (refresh-rotation paths missed). Conclusion unchanged — all four stamp the class — but F2 was specifically about exhaustive enumeration
N12 MINOR consistency artifact S8 vs S9 "Nothing else." and C6 S8 requires an edit to ~/scooling/docs/ADAPTER-CONTRACTS.md that S9's exhaustive table omits and C6 assigns to Scooling
N13 MINOR completeness artifact S5, S10.3 vs §7 No test row for S5's "learner roles can still propose" assertion, and none for S10.3's empty-env branch — the one S10 branch whose failure takes the live notes tray down
N14 MINOR security artifact S10, §9 R9, §12 D3 S10 keys on the approver's sub, and the shared token is a JWT for a human operator account (G3 — there is no machine-credential mint path). Listing that sub also disables that human's own legitimate self-apply from their browser. §3.2's indistinguishability applies to S10 itself; the "closes the single-shared-identity case" claim must be qualified and D3 must warn the operator
N15 MINOR other (honesty) artifact S2.4, §9 R3, S10.3 vs ~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md:86 Three deferrals are justified by "would break the live notes review tray", but the driving audit (a frozen input) records SCOOLING_HOSTED_REVIEW_WRITE_BACK as absent from production, so approve returns hosted_write_env_off. Either the audit is stale or the justification is; §10 does not disclose the tension. (Note: the Scooling handover records the env as set on 2026-07-26, after the audit — this needs one verification, not an assumption)
N16 MINOR other (honesty) artifact header note, §11 round-1 row The header says the mechanical gate "is run with --dry-run between rounds" but no run or verdict is recorded; and round 1's "all verified against source" is overstated given N3 and N11
N17 MINOR consistency hub/gateway/server.mjs:2949, :2969, :3077 S2.1 cites :3077, which is the call-site destructure. resolveHostedActorRole begins at :2949 and has an early return at :2969 that also needs the payload added. Return shape otherwise correct
N18 MINOR consistency hub/icp/src/hub/main.mo:1361, :1372 G19.4's claim holds but :1361 is the note-write branch; the proposals-POST extraction is :1372

11.2 Round-3 findings — the work list for round 4

Round 3's fixes are not withdrawn: the reviewer independently confirmed 15 of 18 round-2 findings resolved and confirmed the D4 = A rewrite closes N1 by construction. What follows is new.

Verification status. The round-3 session re-checked V1, V2, V3, V5, V7 and V8 against source before recording them — including executing V3's counterexample. All six reproduce. V4, V6, V9, V10, V11 are recorded as cited but not yet independently re-derived; round 4 must verify them before amending, per this document's standing rule.

# Sev Category Citation Finding
V1 MAJOR security netlify/functions/consolidation-scheduler.mjs:72-73, used :146; vs. this artifact §3.2, S10.4, §9 R9 "There is no machine-credential mint path" is false. signServiceJwt mints {sub, role: 'service'} with SESSION_SECRETverified by reading source. It carries no type, so it classifies legacy_session (fail-closed, so not a live exploit). But three passages rest the N14 cost-argument on that premise, and D3 was ratified on it. S1's "bar a future machine credential" rationale is also weaker than stated when a present one is unenumerated
V2 MAJOR completeness (security-adjacent) hub/lib/local-auth.mjs:179-192, called :401, mounted hub/gateway/server.mjs:97 and hub/server.mjs:202 A fifth learner-session mint site. issueLocalToken mints the exact web-session shape {sub: 'local:…', provider, id, name, role} on interactive sign-in — verified by reading source. Unstamped, every local-auth / offline-locked session is legacy_session, so after T5 those users' seam self-apply refuses permanently and silently — precisely the failure N9 was raised to prevent. Third consecutive round in which an enumeration declared exhaustive was not (F2 → N9 → V2). Note the operator's ratified D1 wording says "all mint sites", which already covers this
V3 MAJOR consistency this artifact S6.1 "No live behavior change", and §2.1's justification Disproved by execution. A proposal carrying the review-tray fingerprint and knowtation_proposal_source + task_proposal_kind satisfies matchesScoolingReviewTrayFingerprint and S3.1 condition #1; isPersonalSelfApplyClass returns true today and would refuse under S6.1 — the boolean flips. Reachable: frontmatter is client-controlled on the canister proposals-POST (hub/icp/src/hub/main.mo:1371) and returned by the GET serializer (:1156). Fail-closed in direction, so not a hole — but it is a live notes-tray regression, the one thing S2.4 / §9 R3 promise cannot happen. Root cause: §2.1 argues from intent, and D4 = A made classification intent-independent; §2.1 was never updated
V4 MAJOR consistency this artifact §9 "Nothing else." vs. S6 and §7 tier 2 S6 requires the four seam codes to be HTTP-visible at hub/gateway/server.mjs:3116-3120, but §9's exhaustive table stops at :3102-3111. An Auto build honoring "Nothing else." literally cannot make any seam code HTTP-visible on the hosted path
V5 MAJOR completeness lib/hub-proposal-personal-self-apply.mjs:171-173, called hub/gateway/server.mjs:3103, hub/server.mjs:476 The function both approve gates actually call — personalSelfApplyAllowsApprove — appears zero times in this artifact (verified: rg -c returns 0). It is a boolean wrapper, and nothing says how a refusal code traverses it to reach the HTTP layer. Auto must invent that: change the wrapper, bypass it, or add a parallel call. An open design decision inside a freeze
V6 MAJOR completeness this artifact S10.1 vs. §7 tier 7b S10.1 freezes "parsed into a Set once at module load"; tier 7b requires unset / '' / ',, ' exercised in one run. Not buildable together without a seam the freeze does not specify. Sits on the branch D3 makes the one that actually ships
V7 MAJOR completeness (security-adjacent) hub/gateway/server.mjs:1049; hub/bridge/task-routes.mjs:351; hub/bridge/delegation-routes.mjs:181 The correspondence table's exhaustiveness claim is wrongverified: two further apply-approved routes exist. Both require the proposal to be already approved, so the approve-time gate still stands and this is not a self-apply bypass; the defect is that G30 searched for hooks, not apply triggers, and S7.6 builds a future-surface obligation on that enumeration
V8 MAJOR completeness hub/server.mjs:3056, :3064, same handler guarded by requireApproveRole:467-491; ~/scooling/src/adapters/flowHubTransport.ts:983, :1043, token :1119 Flow and flow_capture are apply-bearing, self-apply-gated, and absent from this freezeverified: the self-hosted approve route fires five applies, not three. Scooling drives flow creates over the same shared env token — the identical P3 shape. At T5 a flow proposal would be self-appliable and unbound by S2. Needs either inclusion in S3.1 or an explicit reasoned exclusion (operator scope decision — see D5)
V9 MINOR consistency this artifact S6 code table SELF_APPLY_INTENT_NOT_ELIGIBLE names a concept S3.0 abolished, and covers two structurally different refusals the operator cannot distinguish in logs
V10 MINOR completeness this artifact S3.1 import-safety vs. §9 and S6.1 step 1 The cycle check covers the three predicate modules but not the new lib/**hub/gateway/** edge S10 introduces, which also drags gateway code into the self-hosted server
V11 MINOR other hub/gateway/server.mjs:2953-2962, :2972-2978 vs. S2.1 sessionBound is unspecified when the resolved payload is null. The reviewer records honestly that it could not construct a reachable null, since getUserId requires a verifiable bearer under the same secret — a spec gap, not a demonstrated defect

Undisclosed risk the reviewer names, and it is the sharpest observation in the round. normalizeCanisterProposalForTaskPrecheck was written as an enabling predicate ("does this row have enough metadata to apply?"). S3.1 reuses it as a restricting one. The two purposes pull in opposite directions: a future change making the normalizer more permissive, in order to apply more rows, also silently pulls more proposals — including review-tray ones — into the seam refusal branch. That is the mechanism behind V3. Round 4 extended §9 R10 to state this explicitly and accepted it under D4 = A (fail-closed overlap), with tier-7 overlap tests as the build-time tripwire.

11.3 Round-4 resolutions (V1–V11)

# Resolution
V1 §3.2, S10.4, §9 R9, §3.4 rewritten: signServiceJwt enumerated (G35); "no machine-credential mint path" withdrawn; D3 empty-list outcome unchanged; cost of listing the human sub restated accurately
V2 S1.1 expanded to five learner-session mint sites including issueLocalToken (G36); mounts cited at call sites :510 / :625
V3 §2.1 and S6.1 rewritten from execution: honest notes unchanged; overlap flips fail-closed; absolute "no live behavior change" withdrawn; R10 extended
V4 S9 table now includes hub/gateway/server.mjs:3116-3120 and hub/server.mjs:486-490 for S6.2 HTTP seam codes
V5 New S6.2 freezes personalSelfApplyAllowsApprove + reason→HTTP traversal at both call sites
V6 S10.1 + §7 tier 7b: pure parseSelfApplyIneligibleSubs for empty-env cases; module-load Set for production
V7 G37 + correspondence table: post-approve apply-approved routes recorded; not a self-apply bypass; approve-time coverage complete
V8 / D5 D5 = A — S3.1 conditions #6/#7 (FLOW_PROPOSAL_SOURCE / FLOW_CAPTURE_PROPOSAL_SOURCE); G32–G34
V9 SELF_APPLY_INTENT_NOT_ELIGIBLE deleted; replaced by SELF_APPLY_DELEGATION_REFUSED + SELF_APPLY_NOT_ADMITTED
V10 S10 parser lives under lib/; self-apply must not import hub/gateway/**; import-safety + S9 updated
V11 S2.1: sessionBound === false when payload is null; isSessionBoundActor(null) === false

11.4 Round-4 independent review — W1–W5 (addressed in round 5)

# Sev Resolution
W1 MINOR S2.1 + S9 now require payload on resolveHostedActorRole main return :3025 as well as :2969
W2 MINOR Self-hosted import of access-token-authz.mjs for S1 helpers explicitly authorized; module verified side-effect-free
W3 MINOR Admission frozen empty this phase; step 11 unconditional seam refusal; no admission input/env/allowlist in SEC-SEAM-1b
W4 MINOR §3.4 + S9: docs/ROADMAP.md:49 already has the disclaimer; closing commit = status refresh + D2 media row
W5 MINOR G19.5 + S4.2 record Paperclip hub-client.mjs:65 sending X-User-Id; inert; out of scope to remove

12. Operator decisions required before SEC-SEAM-1b (escalated)

Per .cursor/skills/freeze-review-loop/SKILL.md, security-category items stop the loop for a human. This session does not ratify either decision. A general "proceed" is not a selection (SEC-KN-4a round 2 precedent); the operator must name an option.

D1 — security — stamp a session class claim on minted JWTs?

Adding type: 'session' touches the live authentication mint path (G3, G4).

  • Option A (recommended). Stamp the claim at both mint sites; treat absent type as legacy_session — accepted for propose, ineligible for self-apply. Additive and backward compatible: verification never requires the claim (S1.5), so no existing token breaks. Buys the ability to bar a future dedicated machine credential by name.
  • Option B. Do not touch the mint path. sessionBound then means only "not mcp_access and not an unknown payload", which is nearly vacuous — S2's author-equals-approver clause and S10 carry the whole rule, and a future machine credential could not be distinguished from a learner session by class.
  • Option C (added after freeze review round 1, F13). Do not touch the mint path, and rely on S10 to bar named subjects instead. This delivers most of what option A is wanted for — a future machine credential can be barred by sub the day it is created — without changing live authentication at all. It is weaker than A only in that it is per-subject and operator-maintained rather than by-construction.

Recommendation: A, with C as the low-risk alternative. A is additive and backward compatible (S1.5), and it is the only option where a machine credential is ineligible by construction rather than by an operator remembering to list it. Choose C if touching the authentication mint path is not acceptable in this phase; S10 ships either way, so C is not "do nothing".

D2 — Tier 2 — media surface disposition

Hosted media proposals have no route (G17, G18); self-hosted media propose requires editor/admin (hub/server.mjs:1304) while task propose is viewer-inclusive (G16).

  • Option A (recommended). Keep media out of scope; record the gap (S7) and open a roadmap row for a hosted media proposal surface. SEC-SEAM-1b ships without it.
  • Option B. Expand SEC-SEAM-1b to build the hosted media route. This is new-surface work inside a security-remediation phase and would need its own freeze.

Recommendation: A.

D3 — operational input — which subject backs the shared token?

S10 is inert until the operator names a subject. Knowtation cannot derive it: the sub behind KNOWTATION_AUTH_TOKEN is a Scooling-side operator account, and this session deliberately did not inspect the secret (§10).

The operator must supply the sub value(s) for HUB_SELF_APPLY_INELIGIBLE_SUBS — or state that the list starts empty and S10 ships as a dormant control. Setting the variable in production is Tier 3 (gate T3). Do not put a token, secret, or any credential material in this list or in this document; the value is an account identifier of the form provider:id.

D4 — where seam enforcement lives (raised by round-2 N1 — decide before any fix round)

N1 proved that a parallel field list cannot classify seam proposals safely: classification read frontmatter.proposal_kind while the task apply hook dispatches on frontmatter.knowtation_proposal_source / proposal.source / the meta/tasks/proposals/ path and takes its kind from frontmatter.task_proposal_kindtask_meta.proposal_kindbody.proposal_kind (lib/task/task-hosted-proposal.mjs:85-156). Any list this artifact writes by hand can drift from the list the effect honors, and the gap between them is the evasion. Three ways out:

Option Shape Trade-off
A — reuse the effect's own predicate isSeamSurfaceProposal calls normalizeCanisterProposalForTaskPrecheck(p) != null for tasks, the delegation marker predicate for delegation, and the media equivalent — no hand-written field list at all Single source of truth: a proposal that can trigger the apply is by construction classified seam. Requires one predicate per surface to exist and be import-safe from the self-apply module
B — move enforcement to the apply hooks Leave classification alone; make each apply hook refuse when the approve was self-applied by a non-session-bound actor Enforces exactly where the effect happens, so drift is impossible. But the proposal is already approved on the canister when the hook runs — refusal leaves approved-but-unapplied state, and the media/delegation hooks may not have a comparable choke point
C — both A for classification, B as defence in depth Most robust; largest diff, and B's partial-state problem still needs an answer

Recommendation: A, and treat "no hand-written seam field list may exist in the built code" as a frozen rule so the next drift cannot reappear. A also dissolves N3 and N4, and makes N10's self-hosted shape question moot for tasks, because the normalizer already handles both shapes.

This is a design decision that changes S3 substantially, which is why the loop stopped here instead of rewriting S3 a second time inside a review round.

D5 — scope — are Flow and Flow-capture seam surfaces? (raised by round-3 V8)

RATIFIED as option A (§12.1). Round 4 read lib/flow/**, executed the forgeability check, and recommended A before asking; the operator selected A explicitly.

The self-hosted approve route fires five index applies (G32): flow (hub/server.mjs:3056) and flow_capture (:3064) sit beside task, delegation and media on the same handler, behind the same requireApproveRole self-apply gate (:467-491). Scooling drives flow proposal creates over the same shared env token (~/scooling/src/adapters/flowHubTransport.ts:1119 — G34). A forged fingerprint on source: 'flow' is self-applicable today (executed). Under D5 = A, S3.1 conditions #6 and #7 bind those surfaces with the same proposal.source === … expressions the apply path uses.

Option Shape Trade-off
A (ratified) Add flow + flow_capture as S3.1 conditions #6 and #7 (proposal.source === FLOW_PROPOSAL_SOURCE / FLOW_CAPTURE_PROPOSAL_SOURCE) Closes the gap now, S3.0-compliant (same dispatch field). Widens the phase; ground truth G32–G34 recorded
B (not selected) Exclude explicitly, with a stated reason and a §9 residual risk, and a roadmap row before T5 Would have kept the freeze narrower at the cost of leaving an apply-bearing, self-apply-gated P3 surface unbound by S2 until a later row

Why B was offered at all. Round 3 had not read the flow write path and correctly refused to guess a security scope boundary. B is the scope-brake for that state: freeze what was reviewed, track the gap, hard-gate T5 on closing it. After round 4's ground-truth pass, B would only re-open a known hole; A is the recommendation and the selection.

D3 — reopened by round-3 V1 (the ratified premise is false) — DISCLOSED

This was not a request to re-ratify; it is a disclosure. D3 was presented with the warning that S10 "cannot be used without cost" because the shared token belongs to a human operator account and "there is no machine-credential mint path". V1 disproved that premise from source: signServiceJwt (netlify/functions/consolidation-scheduler.mjs:72-73) mints a role: 'service' JWT with SESSION_SECRET (G35).

The operator's selection — start empty, ship S10 dormant — is unaffected in outcome: an empty list has the same effect either way, and a service JWT is already self-apply-ineligible under S1 because it carries no type claim. Round 4 corrected §3.2, S10.4 and §9 R9 so the cost argument for listing the human sub is accurate before T3 ever populates the list.

12.1 Ratification record

RATIFIED 2026-07-27 — D1–D4 by explicit operator selection in the round-3 fix session (four-question prompt that named each option). RATIFIED 2026-07-27 — D5 = A by explicit operator selection in the round-4 fix session after a grounded recommendation (not a bare "proceed").

Decision Operator selection (verbatim)
D1 "A (freeze's recommendation) — stamp the claim at all mint sites; absent type is treated as legacy_session: accepted for propose, ineligible for self-apply. Additive and backward compatible; a future machine credential is ineligible by construction"
D2 "A (freeze's recommendation) — keep media out of scope, record the gap as S7, open a roadmap row for a hosted media proposal surface. SEC-SEAM-1b ships without it"
D3 "Start empty — S10 ships as a dormant control, no subject named yet. Avoids the N14 side effect; the list can be populated later (setting it in production is Tier 3 gate T3)"
D4 "A (freeze's recommendation) — classification reuses the effect's own predicate (normalizeCanisterProposalForTaskPrecheck + per-surface siblings); no hand-written seam field list may exist in built code. Also dissolves N3, N4, most of N10"
D5 "D5 = A — include flow + flow_capture in S3.1"

Scope of this ratification. It authorizes the SEC-SEAM-1b code build only. Tier-3 gates T1–T5 (§8) remain unexecuted and unauthorized — in particular T3 (setting HUB_SELF_APPLY_INELIGIBLE_SUBS, or any seam env flip, in production) and T2 (merge / muse-mirror pull request). Ratification is not freeze clearance: SEC-SEAM-1b may not start until §11 records an independent reviewer verdict of pass.

D3 consequence, recorded explicitly. HUB_SELF_APPLY_INELIGIBLE_SUBS ships empty. S10 is therefore a dormant control in this phase — it bars nobody until an operator populates it, which is Tier 3 (T3). Every claim in this document about S10 "closing" the shared-identity case is conditional on that population and is qualified as such (S10.4, §3.2, §9 R9 — see N14 / V1).

Governance note (preserved deliberately). A general instruction to proceed is not a selection, and an authoring session may never ratify its own escalations (docs/SEC-KN-4-DELEGATION-PRINCIPAL-BINDING-FREEZE.md:504-509). Round 3 opened with the paste-ready prompt in docs/OVERSEER-HANDOVER.md whose text read "fix N1 per D4 option A". That clause was prior-session authorship and was not treated as ratification. Round 4 likewise obtained an explicit D5 selection before touching S3.1 scope.

File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago