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_kind → proposal.task_meta.proposal_kind → JSON.parse(proposal.body).proposal_kind. task_proposal_kind ≠ proposal_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 → resolveHostedAuthSessionContext → rawToken; 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)
- The canister trusts gateway-minted headers, guarded only by
X-Gateway-Auth. Everything in this freeze that relies oncreated_byinherits SEC-KN-4 §3.2(1) unchanged: it is exactly as trustworthy asX-User-Idpartitioning, which is acceptable only because SEC-KN-1 made the empty-secret branch deny and SEC-KN-0 verified the secret is set. created_byis not live until the SEC-KN-4 Tier-3 canister upgrade (gate T1). Until then every hostedcreated_byis""(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.PROXY_HEADER_ALLOWLIST(G5) is the control that keepsx-actor-idun-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
signServiceJwtpath (G35), which today carries notypeand therefore classifieslegacy_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 notype, and is already self-apply-ineligible under S1 aslegacy_session. Listing the operator's humansubtherefore still disables that human's own legitimate self-apply from their own browser, because the server cannot tell the two uses of thatsubapart. 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_SUBSships empty, so inSEC-SEAM-1bS10 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:
- 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. - S2 — author must equal approver, which makes any mixed service/learner combination ineligible regardless of declaration.
- S1 — a class marker so a dedicated machine credential is ineligible by construction. The
present
signServiceJwtmint (G35) already lackstypeand classifieslegacy_session; a future mint that stamps a non-sessionclass 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.)
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 throughissueAccessTokenForSub(G4) and needs no separate change. (V2: round 3's "all four" enumeration missedissueLocalToken.)| 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 viaregisterLocalAuthRoutesathub/gateway/server.mjs:510andhub/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. InSEC-SEAM-1bitself 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.)issueLocalTokenis mandatory (V2). Unstamped, every local-auth / offline-locked session islegacy_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 aslegacy_sessionso it can never satisfy S2 (G3). Do not stampsignServiceJwt(netlify/functions/consolidation-scheduler.mjs:72-73) — it is a machine credential (G35) and must remainlegacy_session. A unit test pins both classifications.Add
resolveActorTokenClass(payload)tohub/gateway/access-token-authz.mjs, returning exactly one of'session'|'mcp_access'|'legacy_session'|'unknown':'mcp_access'whenisMcpAccessPayload(payload)(reuse:28-30; do not re-implement)'session'whenpayload.type === 'session''legacy_session'whenpayloadis an object with a non-empty stringsuband notype'unknown'otherwise, includingnull/ non-object
Add
isSessionBoundActor(payload)returningtrueonly for'session'. Every other class, including'legacy_session', returnsfalse.'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.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:
Add optional inputs to
isPersonalSelfApplyClass:authorActorId,approverActorId,sessionBound.When
isSeamSurfaceProposal(proposal)returnstrue(S3.1), all of the following must hold or the class is refused:sessionBound === trueauthorActorIdis a non-empty string after trimapproverActorIdis a non-empty string after trimauthorActorId === approverActorIdafter trim (exact, case-sensitive — uids are opaque)
The trigger is
isSeamSurfaceProposal, not the proposal'sintent(N4). Round 1 left this clause reading "when the proposal's intent is inSEAM_SURFACE_INTENTS"; implemented literally that reinstates the F3/N1 defect, becauseintentis 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).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.
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
sourceis none of task/delegation/media and it carries no task or delegation frontmatter marker), so it never enters the seam branch.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.mjs → hub/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-1bships. 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
- 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. - Remove
X-User-Idfrom the CORSAccess-Control-Allow-Headerson both JavaScript surfaces —hub/gateway/cors-middleware.mjs:60andhub/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 forSEC-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. - The canister's own CORS header (
hub/icp/src/hub/main.mo:221) is left as-is, deliberately. The canister is reachable only behindX-Gateway-Auth, it is the one component that legitimately does readX-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. 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:
- Keep
personalSelfApplyAllowsApprove(opts)aspersonalSelfApplyRefusalReason(opts) === null(same boolean contract for any other caller). - At both call sites, replace the bare boolean branch with:
const reason = personalSelfApplyRefusalReason(optsWithS2Inputs);- if
reason === null→ allow (same as today'spersonalSelfApplyAllowsApprove === true) - if
reasonis one of the HTTP-visible seam codes in the S6 table → HTTP 403 with{ error: <stable message>, code: reason }— never collapsed toFORBIDDEN - otherwise (non-seam codes and
SELF_APPLY_SUBJECT_INELIGIBLE) → today's generic 403FORBIDDENbody athub/gateway/server.mjs:3116-3120andhub/server.mjs:486-490
- 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:
SEC-SEAM-1bmust not build a hosted media proposal route. That is a new surface, not a P3 fix.docs/PROPOSAL-LIFECYCLE.mdmust record that media proposals are self-hosted-only today and that the hosted media transport is unreachable.- A test must assert the gateway exposes no
api/v1/attachments/*route, so a later phase cannot add one without deliberately updating this freeze. - 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.
- §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-1bships without it. - When a hosted media route is later built, it must ship with a
maybeApplyHostedMediaAfterApprovehook 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.
- Add
HUB_SELF_APPLY_INELIGIBLE_SUBS, a comma-separated env list ofsubvalues permanently ineligible for personal self-apply — compared exactly, never pattern-matched. Parse shape copieshub/gateway/server.mjs:156-160(HUB_ADMIN_USER_IDS→.split(',').map(trim).filter(Boolean)→Set), but the implementation must not live inhub/gateway/**(V10): put a pureparseSelfApplyIneligibleSubs(raw)inlib/hub-self-apply-ineligible.mjs(or an equivalentlib/**module that does not importhub/**). Production wiresprocess.env.HUB_SELF_APPLY_INELIGIBLE_SUBSthrough that parser once at module load into a module-levelSet. Tier 7b exercises unset /''/',, 'by calling the pure parser directly with those strings in one run — not by mutatingprocess.envand hoping module load re-runs (V6). Round 1 citedhub/gateway/access-token-authz.mjs:75-77and:55-66for this; both citations were wrong (N8). - When the approving actor's
subis on the list,personalSelfApplyRefusalReasonreturnsSELF_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). - 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).
- 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 aslegacy_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-suppliedintent(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-39 — security 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 | Dissolved — SEAM_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 | Fixed — SELF_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 | Fixed — ADAPTER-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 | Fixed — resolveHostedActorRole 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_kind → task_meta.proposal_kind → JSON.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_SECRET — verified 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 wrong — verified: 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 freeze — verified: 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
typeaslegacy_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.
sessionBoundthen means only "notmcp_accessand 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
subthe 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-1bships without it. - Option B. Expand
SEC-SEAM-1bto 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_kind → task_meta.proposal_kind → body.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.