SEC-SEAM-MEDIA — Hosted media proposal surface (Knowtation)
Phase: SEC-SEAM-MEDIA (SEC-SEAM-MEDIA-a Thinking freeze → SEC-SEAM-MEDIA-b Auto)
Freeze status: CLEARED for SEC-SEAM-MEDIA-b — freeze-review pass (mechanical + semantic)
Date: 2026-08-01
Model (this artifact): Thinking
Owner repo: Knowtation
Ratified parent: SEC-SEAM-1 §12 D2 = A (media out of SEC-SEAM-1b; open hosted media row)
Status: Frozen Thinking outline. Spec only — no product code in this step. No
MEDIA_EXTERNAL_LINK_ENABLED / MEDIA_ATTACH_ENABLED flip. No Scooling
SCOOLING_MEDIA_* / MEDIA_*_AUTHORIZED flip. No deploy. Do not unpark
SEC-KN-P6-ROTATE R4–R5 (Tier-3 T2 secret writes) in this session. Downstream Auto
(SEC-SEAM-MEDIA-b) implements this contract only after freeze-review pass.
phase: SEC-SEAM-MEDIA-a
outputs:
- id: sec-seam-media-freeze
path: docs/SEC-SEAM-MEDIA-FREEZE.md
frozen: true
notes: 'Hosted media propose+apply surface: gateway maybeApplyHostedMediaAfterApprove + matching isSeamSurfaceProposal S3.1 condition via normalizeCanisterProposalForMediaPrecheck in the SAME change (S3.0); bridge propose/apply-approved/consent/list proxies; blob-backed media stores; no posture/env flip; no P6 R4-R5
'
frozen_inputs:
- id: sec-seam-1-freeze
path: docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md
notes: frozen:true; review_stamp pass; S3.0 anti-drift; S7.6 hook+S3.1 same-change; D2=A
- id: media-write-contract
path: docs/MEDIA-WRITE-SURFACES-CONTRACT-2F-b-d-kn.md
notes: frozen:true; media_external_link + media_attach; self-hosted v0; hosted proxy deferred (this phase)
- id: proposal-lifecycle
path: docs/PROPOSAL-LIFECYCLE.md
notes: Media self-hosted-only today; requires maybeApplyHostedMediaAfterApprove + seam condition
- id: capture-hosted-apply-freeze
path: docs/CAPTURE-HOSTED-APPLY-FREEZE.md
notes: frozen:true; review_stamp pass; CHA-C1 hook + apply-approved + blob pattern
- id: gateway-approve-hooks
path: hub/gateway/server.mjs
notes: post-approve delegation/task/capture hooks; no media hook; no attachments routes
- id: task-approve-hosted
path: hub/gateway/task-approve-hosted.mjs
notes: maybeApplyHostedTaskAfterApprove + mergeTaskApplyIntoApproveResponse pattern
- id: capture-approve-hosted
path: hub/gateway/capture-approve-hosted.mjs
notes: maybeApplyHostedCaptureAfterApprove pattern (newest sibling)
- id: attachment-write
path: lib/attachments/attachment-write.mjs
notes: handleMedia*Propose + precheckApprovedMediaProposal + reconcileApprovedMediaProposal
- id: personal-self-apply
path: lib/hub-proposal-personal-self-apply.mjs
notes: isSeamSurfaceProposal S3.1; MEDIA source only today; no media normalize
- id: self-hosted-media-routes
path: hub/server.mjs
notes: attachments list/get/propose/consent + media apply on approve
- id: external-protocol-blob
path: hub/bridge/external-agent-blob-store.mjs
notes: EXTERNAL_PROTOCOL_BLOB_FILES — media store files absent today
- id: scooling-media-transport
path: ../scooling/src/adapters/mediaWriteHubTransport.ts
notes: hosted targets api/v1/attachments/link-proposals and attach-proposals
review_stamp:
reviewed_at: '2026-08-01T16:17:27Z'
verdict: pass
reviewer_mode: agent
reviewer_model: thinking-high
reviewer_provider: local
kit_version: 0.1.0
artifact_digest: sha256:f9c58fd31deb88b134d2b01bdd2fb187789e9205c11933b7ba9a86ebe80648f6
downstream:
- id: SEC-SEAM-MEDIA-b
model: Auto
consumes_as_ground_truth: true
notes: Implement SM-C1–C12 exactly; seven-tier green; BV pass; no posture/env flip; no P6 R4-R5
tier3_gates:
- T1 Muse main / muse-mirror to GitHub main (SD-14) for SEC-SEAM-MEDIA-b land
- T2 Production gateway/bridge deploy that mounts hosted media routes or the media approve hook
- T3 Any MEDIA_EXTERNAL_LINK_ENABLED / MEDIA_ATTACH_ENABLED production write (or Scooling MEDIA_*_AUTHORIZED / SCOOLING_MEDIA_* flip)
- T4 SEC-KN-P6-ROTATE R4-R5 SESSION_SECRET writes (explicitly out of this phase)
- T5 GitHub PR head that is not muse-mirror targeting main (SD-14)
Review record
| Round | Reviewer | Verdict | Resolution |
|---|---|---|---|
| 0 | Thinking (this session) | draft | Freeze authored from SEC-SEAM-1 S7.6 + source ground truth |
| 1 | ok review --freeze --dry-run + thinking review |
findings | CLI-F1 / R1-F1 / R1-F2 fixed below |
| 2 | Freeze-review loop (thinking) | findings | R2-F1 / R2-F2 fixed below |
| 3 | Freeze-review loop (thinking) + ok review --freeze |
pass | R1/R2 hold. CLI C checklist clean. Semantic re-read: SM-C1–C12 implementable; S3.0 same-change load-bearing; media_attach IO adapter unambiguous; no escalating category open. Cleared for SEC-SEAM-MEDIA-b Auto. |
Round 1 findings (cited — file+line)
| ID | Sev | Cat | Citation | Finding | Fix |
|---|---|---|---|---|---|
| CLI-F1 | BLOCKER | security | docs/SEC-SEAM-MEDIA-FREEZE.md:361 (pre-fix) | Leading-slash Hub route glob matched absolute-path checklist (C4). | Rewrote as prose api/v1 (no leading slash). |
| R1-F1 | MAJOR | completeness | docs/SEC-SEAM-MEDIA-FREEZE.md:§SM-C5 (pre-fix); hub/icp/src/hub/main.mo:404-411 | SM-C5 said “existing hosted note write path” without naming the canister notes surface Auto must reuse — Auto could invent a parallel write. | SM-C5 now requires canister notes GET/POST family; forbids a second note-write protocol. |
| R1-F2 | MINOR | consistency | docs/SEC-SEAM-MEDIA-FREEZE.md:G2 | G2 end line stopped before capture import close. | G2 → hub/gateway/server.mjs:68-78. |
Round 2 findings (cited — file+line)
| ID | Sev | Cat | Citation | Finding | Fix |
|---|---|---|---|---|---|
| R2-F1 | MAJOR | completeness | lib/attachments/attachment-write.mjs:825-907; docs/SEC-SEAM-MEDIA-FREEZE.md:§SM-C4/C5 (pre-fix) | SM-C4 “same function” conflicted with SM-C5 canister-only note I/O — media_attach precheck requires ctx.vaultPath. Auto could fork a hosted precheck. |
SM-C5 now freezes temp-stage or thin noteIO adapter; forbids hosted-only precheck fork. |
| R2-F2 | MINOR | completeness | docs/MEDIA-WRITE-SURFACES-CONTRACT-2F-b-d-kn.md:35, :711 | OpenAPI-with-routes obligation from MEDIA contract omitted from in-scope table. | Added OpenAPI deliverable to §SM.0 in-scope. |
Simple summary
Learners and editors can already propose media changes on a self-hosted Hub (link an
external item, or attach media to a note). On the hosted product
(api.knowtation.store) those same URLs hit the canister catch-all and return
not-found — so nothing is proposed, and approving would not apply even if a
proposal somehow existed. This freeze specifies the missing hosted path: create
media proposals through the gateway/bridge into the canister review tray, then
after an admin or evaluator approves, run the same media apply the self-hosted
Hub already runs. The security rule from SEC-SEAM-1 stays: the apply hook and the
seam classifier must share one predicate, shipped in the same change.
Technical summary
SEC-SEAM-MEDIA closes the gap recorded by SEC-SEAM-1 S7 / G17 / G30 and
PROPOSAL-LIFECYCLE: no hosted api/v1/attachments/* routes; no
maybeApplyHostedMediaAfterApprove. Self-hosted already implements
handleMediaLinkProposeRequest / handleMediaAttachProposeRequest,
precheckApprovedMediaProposal, and reconcileApprovedMediaProposal with
source: media. Hosted must mirror the task/capture pattern: canister proposal
create with embedded frontmatter markers, gateway post-approve hook, bridge
apply-approved, blob-backed media stores. S3.0: Auto MUST add
normalizeCanisterProposalForMediaPrecheck and wire it as both the hook trigger
and a new isSeamSurfaceProposal condition in the same change — never a
hand-written kind/intent list.
§SM.0 — Scope and hard stops
In scope (SEC-SEAM-MEDIA-a freeze → SEC-SEAM-MEDIA-b Auto)
| Deliverable | Owner |
|---|---|
maybeApplyHostedMediaAfterApprove + response merge on gateway approve success |
Knowtation |
normalizeCanisterProposalForMediaPrecheck + S3.1 condition in isSeamSurfaceProposal same change (S3.0) |
Knowtation |
| Bridge propose routes for link/attach + import-consent grant/list/revoke | Knowtation |
Bridge apply-approved + applyApprovedMediaProposalFromCanister |
Knowtation |
| Gateway proxies for those surfaces before canister catch-all | Knowtation |
| Hosted attachment list/get (at least connector_ref / external-ref derivation) | Knowtation |
| Blob hydrate/persist for media store files | Knowtation |
| OpenAPI shapes for new hosted attachment write/apply routes in the same change as routes (MEDIA contract §0 / §18 parity) | Knowtation |
| PROPOSAL-LIFECYCLE honesty update (hosted path exists; gates still default off) | Knowtation |
| Seven-tier tests | Knowtation |
Explicitly NOT in scope
| Out of scope | Why |
|---|---|
Flip MEDIA_EXTERNAL_LINK_ENABLED / MEDIA_ATTACH_ENABLED |
Separate Tier 3 (T3) |
Flip Scooling MEDIA_*_AUTHORIZED / SCOOLING_MEDIA_* |
Consumer Tier 3 |
| SEC-KN-P6-ROTATE R4–R5 secret writes | Operator-parked; Tier-3 T2 |
| Detach/unlink proposal kind; byte import/mirror; connector SDK fetch | MEDIA contract non-goals |
Change MEDIA_WRITE_ROLES (editor/admin) |
SEC-SEAM-1 / MEDIA contract posture |
| Redesign SEC-SEAM-1 S1–S6/S10 | Parent freeze closed |
| MuseHub F7 | AWS-parked |
Feature → GitHub-main |
SD-14 |
| Auto product code in this Thinking turn | Model boundary |
Hard stops
- No posture / production env flip inside MEDIA-a or MEDIA-b
- No
gh pr create --base main --head feat/… - No SESSION_SECRET / SESSION_SECRET_PREVIOUS writes
- No Auto product code until freeze-review
pass - No hand-written seam field/kind/intent list (S3.0)
§SM.1 — Ground truth (verified 2026-08-01)
Every row was read in this session.
| # | Fact | Citation |
|---|---|---|
| G1 | Gateway post-approve block calls delegation, task, then capture apply hooks only — no media hook | hub/gateway/server.mjs:3557-3607 |
| G2 | Imports exist for delegation/task/capture apply helpers; no media approve module | hub/gateway/server.mjs:68-78 |
| G3 | Task hook pattern: fetch canister → classify via normalizeCanisterProposalForTaskPrecheck → bridge apply-approved → merge task_index_applied |
hub/gateway/task-approve-hosted.mjs:17-18, :36-92, :102-118 |
| G4 | Capture hook same shape with capture_index_applied |
hub/gateway/capture-approve-hosted.mjs (full module) |
| G5 | Self-hosted media propose routes: link-proposals, attach-proposals, import-consents; roles editor/admin (consent read includes viewer/evaluator) | hub/server.mjs:1325-1426 |
| G6 | Self-hosted attachment list/get exist | hub/server.mjs:1292, :1443 |
| G7 | Gateway has zero attachments route registrations — paths fall through canister catch-all |
hub/gateway/server.mjs (no match); catch-all :4095 |
| G8 | Bridge has zero attachment/media route modules | hub/bridge/ (no *media* / *attach* route file) |
| G9 | Self-hosted approve prechecks media when source === MEDIA_PROPOSAL_SOURCE, then reconcileApprovedMediaProposal |
hub/server.mjs:3133-3142, :3172-3173 |
| G10 | MEDIA_PROPOSAL_SOURCE === 'media'; kinds media_external_link / media_attach |
lib/attachments/attachment-write.mjs:54, :781, :825 |
| G11 | precheckApprovedMediaProposal / reconcileApprovedMediaProposal are the shared apply truth |
lib/attachments/attachment-write.mjs:770-911 |
| G12 | isSeamSurfaceProposal classifies media only via proposal.source === MEDIA_PROPOSAL_SOURCE — no media normalize predicate |
lib/hub-proposal-personal-self-apply.mjs:447-465 |
| G13 | SEC-SEAM-1 S3.0 forbids hand-written seam lists; S7.6 requires hook and matching S3.1 condition in the same change | docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md:428-440, :708-711 |
| G14 | SEC-SEAM-1 D2 = A ratified — media out of 1b; roadmap row opened | docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md:1227 |
| G15 | MEDIA write contract deferred hosted gateway proxy | docs/MEDIA-WRITE-SURFACES-CONTRACT-2F-b-d-kn.md:85-86, :692 |
| G16 | PROPOSAL-LIFECYCLE records self-hosted-only media + future hook obligation | docs/PROPOSAL-LIFECYCLE.md:103-105 |
| G17 | Scooling hosted write transport targets api/v1/attachments/link-proposals and attach-proposals on api.knowtation.store |
../scooling/src/adapters/mediaWriteHubTransport.ts:361-367, :558, :578 |
| G18 | EXTERNAL_PROTOCOL_BLOB_FILES is flow store + idempotency + delegation audit only — no media store filenames |
hub/bridge/external-agent-blob-store.mjs:15-19 |
| G19 | Media durable files: hub_attachment_external_refs.json, hub_media_import_consent.json, hub_media_connector_policy.json, hub_media_write_policy.json |
lib/attachments/attachment-external-ref-store.mjs:13; media-import-consent.mjs:13; media-connector-policy.mjs:13; attachment-write.mjs:52 |
| G20 | Task hosted create embeds FM markers via mergeTaskFrontmatter; normalize is the hook trigger |
lib/task/task-hosted-proposal.mjs:60-77, :85-94 |
| G21 | T5 media fingerprint requires proposal.source === MEDIA_PROPOSAL_SOURCE plus admitted kinds + path + external_ref |
lib/hub-proposal-personal-self-apply.mjs:285-316 |
| G22 | media_attach apply uses local writeNote + resolveMediaPointerForAttach (vault walk for att_mist_*) |
lib/attachments/attachment-write.mjs:235-271, :886-907 |
| G23 | media_external_link apply upserts external-ref store only (dataDir — bridge-friendly) | lib/attachments/attachment-write.mjs:873-883 |
| G24 | Gates default off via env tri-state + policy file | lib/attachments/attachment-write.mjs:99-111; hub/server.mjs:1324 |
§SM.2 — Frozen rules (WHAT)
SM-C1 — Gateway post-approve media hook (parity; S7.6)
Rule: On successful hosted canister POST …/proposals/:id/approve (same gate block as
hub/gateway/server.mjs:3548-3607), the gateway MUST call
maybeApplyHostedMediaAfterApprove and merge the outcome into the approve response body.
| Field (approve JSON) | Meaning |
|---|---|
media_index_applied |
true when bridge apply succeeded; false when media proposal classified but apply failed |
media_apply |
Bridge payload on success (include applied, proposal_id, proposal_kind, and kind-specific ids) |
media_apply_error / media_apply_code |
Present when classified but applied: false |
Null outcome: If normalize returns null / non-media, the hook returns null and the
response body is unchanged (task/capture parity).
HTTP status: Canister approve success remains primary. Apply failure is non-fatal
to HTTP status but MUST surface media_index_applied: false + error fields. Auto MUST
console.error on failure (parity).
Module location: New hub/gateway/media-approve-hosted.mjs (do not inline the hook
body into server.mjs beyond import + call + merge). Call order after the capture hook
in the approve success block (delegation → task → capture → media).
SM-C2 — S3.0 / S3.1 same-change seam condition (load-bearing)
Rule: Auto MUST introduce
normalizeCanisterProposalForMediaPrecheck(proposal)
in lib/attachments/media-hosted-proposal.mjs (or equivalent lib/attachments/** module
that does not import hub/gateway/**), and in the same change:
- The media approve hook's classify step MUST be
normalizeCanisterProposalForMediaPrecheck(proposal) != null(identical call, not a copied field list). isSeamSurfaceProposalMUST gain a condition:normalizeCanisterProposalForMediaPrecheck(proposal) != null(hosted media rows that carry markers only in frontmatter).- Existing
proposal.source === MEDIA_PROPOSAL_SOURCEcondition MUST remain (self-hosted).
Forbidden (S3.0): any new SEAM_* array of kinds/intents/field names used as a
parallel classifier; any seam condition that reads a different key than the apply path
honors (the N1 class of defect).
Normalize recognition (minimum union — mirror task G20): return non-null when any of:
- frontmatter
knowtation_proposal_source === MEDIA_PROPOSAL_SOURCE(FM key parity with task), or proposal.source === MEDIA_PROPOSAL_SOURCE, or- path prefix
meta/media/proposals/(mirror path family used by self-hosted media propose)
and the proposal carries a resolvable media kind (media_external_link | media_attach)
from frontmatter / media_meta / body JSON (fail-closed null if kind absent/unknown).
Fail-closed: if normalize throws, isSeamSurfaceProposal treats as seam (existing
S3.1 catch). Normalize itself MUST be total over arbitrary input (object guards +
defensive parse) like task/delegation normalizers.
Correspondence table (security-tier must assert):
| Apply trigger | Covered by |
|---|---|
| Hosted media approve hook (SM-C1) | normalizeCanisterProposalForMediaPrecheck != null |
| Self-hosted media apply | proposal.source === MEDIA_PROPOSAL_SOURCE (unchanged) |
SM-C3 — Bridge propose + canister create helper
Rule: Bridge MUST expose (behind requireBridgeAuth + vault context + role gates
matching self-hosted MEDIA_WRITE_ROLES / consent read roles):
| Method + path | Handler family |
|---|---|
POST api/v1/attachments/link-proposals |
handleMediaLinkProposeRequest with createProposal → canister |
POST api/v1/attachments/attach-proposals |
handleMediaAttachProposeRequest with createProposal → canister |
POST api/v1/attachments/import-consents |
grant |
GET api/v1/attachments/import-consents |
list |
DELETE api/v1/attachments/import-consents/:id |
revoke |
Helper createMediaProposalOnCanister MUST embed media markers via a
mergeMediaFrontmatter (parity mergeTaskFrontmatter) so canister rows survive without
a media_meta column — FM keys at minimum:
knowtation_proposal_source=mediamedia_proposal_kind=media_external_link|media_attach- kind-specific ids (
attachment_id,connector_id,consent_id,note_refas applicable)
E1: When sessionBound === true and the fingerprint admits, create-time evaluation
satisfaction MAY set evaluation_status: passed via the existing
applyPersonalSelfApplyEvaluationE1 path (task hosted parity). Normalize MUST set
source: MEDIA_PROPOSAL_SOURCE on the normalized object so T5 fingerprint (G21) can match
hosted rows after normalize.
Gates: Propose handlers still honor getMediaExternalLinkEnabled /
getMediaAttachEnabled (default off). Hosted routes with gates off return the same
refusal codes as self-hosted.
SM-C4 — Bridge apply-approved + shared precheck/apply
Rule: Bridge MUST expose:
POST api/v1/attachments/proposals/:proposal_id/apply-approved
behind requireBridgeAuth, vault context, and canister header pattern parity with task
apply-approved.
Helper:
applyApprovedMediaProposalFromCanister({ dataDir, canisterUrl, headers, proposalId, requireApproved, vaultPath?, vaultConfig? })
| Step | Behavior |
|---|---|
| 1 | Fetch canister proposal + normalizeCanisterProposalForMediaPrecheck |
| 2 | If normalize null → 400 BAD_REQUEST |
| 3 | If requireApproved !== false and status !== 'approved' → 409 CONFLICT |
| 4 | Run media precheck (SM-C5) — refusal codes MUST match self-hosted |
| 5 | On precheck fail → return that status/code/error (no mutate) |
| 6 | Apply per SM-C5 |
| 7 | Persist media blob files (SM-C6) |
| 8 | Return { applied: true, proposal_id, vault_id, proposal_kind, … } |
Gateway hook calls this route with learner Authorization + X-Vault-Id (task parity).
SM-C5 — Kind effects + hosted vault asymmetry
Rule: Hosted Hub-complete apply MUST reach the same canonical effects as
reconcileApprovedMediaProposal for both kinds, with the following hosted HOW constraint:
proposal_kind |
Canonical effect | Hosted HOW |
|---|---|---|
media_external_link |
upsert hub_attachment_external_refs.json |
Call precheckApprovedMediaProposal + reconcileApprovedMediaProposal on bridge dataDir + blob (G23) — identical functions |
media_attach |
append pointer to target note attachments[] |
Canister note read-modify-write via existing canister note HTTP surface (hub/icp/src/hub/main.mo:404-411 notes routes — GET/POST api/v1/notes family already used by gateway catch-all). MUST NOT depend on Netlify-local vault filesystem walk |
media_attach IO adapter (required — resolves precheck/vaultPath tension):
Self-hosted precheckApprovedMediaProposal / reconcileApprovedMediaProposal read/write
notes through ctx.vaultPath (lib/attachments/attachment-write.mjs:825-907). Hosted Auto
MUST choose exactly one of:
- Temp stage: GET canister note → write into a per-request temp vaultPath → call the
same
precheckApprovedMediaProposal+reconcileApprovedMediaProposal→ POST/write the mutated note back to canister → discard temp dir; or - Thin noteIO hook: extend those two functions with an optional note reader/writer that defaults to today's filesystem helpers and, on hosted, delegates to canister notes — without changing refusal codes or branching a second precheck.
Forking a hosted-only precheck that re-implements connector/consent/lineage rules is forbidden. Auto MUST NOT invent a second note-write protocol outside the canister notes surface above.
Propose-time pointer stamp (media_attach): When creating a hosted attach proposal,
Auto MUST resolve the attach pointer once (via resolveMediaPointerForAttach when a
vaultPath is available, or an equivalent hosted resolver) and persist it on the proposal
as media_meta.media_pointer and frontmatter media_pointer. Apply MUST prefer that
stamped pointer and MUST NOT require a vault-wide mist walk on the bridge lambda (G22).
If the attachment cannot be resolved at propose time → refuse 404 unknown_attachment
(same as self-hosted) — no silent propose.
Note concurrency: media_attach precheck still enforces base_state_id / live note
state against the canister-fresh note (via the adapter above), not an empty local vault.
Precheck refusal codes (MEDIA_CONNECTOR_DENIED, MEDIA_IMPORT_CONSENT_REQUIRED,
MEDIA_LINEAGE_CONFLICT, MEDIA_DRAFT_INVALID, …) MUST come from the shared precheck —
no hosted-only fork.
SM-C6 — Blob hydrate/persist for media stores
Rule: The following files MUST be hydrated before media propose/apply/list reads that depend on them, and persisted after mutating writes (parity capture blob sync):
hub_attachment_external_refs.jsonhub_media_import_consent.jsonhub_media_connector_policy.jsonhub_media_write_policy.json
Auto may extend EXTERNAL_PROTOCOL_BLOB_FILES or add a dedicated media blob helper
module — either is fine if every mutating media bridge route and list/get that reads
those stores hydrates first. Merge strategy for concurrent warm-lambda writes MUST be
id-keyed union with newest-updated wins for external refs (parity
CAPTURE-STORE-STALE-MERGE lessons) — document the chosen merge in code comments + tests.
SM-C7 — Gateway proxies + attachment list/get exposure
Rule: Gateway MUST proxy to BRIDGE_URL before the canister catch-all:
POST api/v1/attachments/link-proposalsPOST api/v1/attachments/attach-proposalsPOST|GET|DELETE api/v1/attachments/import-consents[/:id]POST api/v1/attachments/proposals/:proposal_id/apply-approved(ops re-apply; optional for clients, mandatory for the hook)GET api/v1/attachmentsandGET api/v1/attachments/:id
Static-path ordering MUST NOT steal unrelated api/v1 routes outside the attachment
surface. Attachment list/get on bridge reuse handleAttachmentListRequest /
handleAttachmentGetRequest with blob hydrate so connector_ref rows from external-link
apply are visible to Scooling hosted reads.
SM-C8 — Session-bound seam + T5 posture (no redesign)
Rule:
- Hosted media propose is a seam surface under SM-C2. SEC-SEAM-1 session-binding / author-match / named refusals apply unchanged.
- T5 admission for media fingerprints (already admitted by FINISH-COMPLETE-APPLY)
remains; Auto MUST NOT remove media from T5. Hosted normalize MUST make fingerprint
evaluation possible (SM-C3
sourceon normalized object). - Delegation stays out of media paths. Capture/Flow authoring hooks untouched.
SM-C9 — PROPOSAL-LIFECYCLE honesty
Rule: Replace the “self-hosted only today” media subsection with:
- Hosted media propose/apply exists via gateway→bridge (SM-C1–C7) when gates allow.
- Seam classification uses
normalizeCanisterProposalForMediaPrecheck(hosted) andsource: media(self-hosted) — S3.0. - Hub gates
MEDIA_EXTERNAL_LINK_ENABLED/MEDIA_ATTACH_ENABLEDstill default off; production flips remain Tier 3. - Hub-complete approve invokes
maybeApplyHostedMediaAfterApprove; apply failure is non-fatal to approve status (media_index_applied: false).
SM-C10 — No posture / env flip; no P6 unpark
Rule: SEC-SEAM-MEDIA-a and SEC-SEAM-MEDIA-b MUST NOT:
- set production
MEDIA_EXTERNAL_LINK_ENABLED/MEDIA_ATTACH_ENABLED - flip Scooling media authorize/env flags
- execute SEC-KN-P6-ROTATE R4–R5 or write
SESSION_SECRET*
SM-C11 — Fail-closed security properties
| Threat | Required behavior |
|---|---|
| Apply before approve | Bridge requireApproved: true → 409 if not approved |
| Non-media proposal hit apply route | 400; no store/note mutate |
| Parallel seam list (N1 class) | Forbidden — SM-C2 source-scan in tier 7 |
| Cross-vault / wrong partition | Bridge vault context + canister X-User-Id / X-Actor-Id / X-Vault-Id |
| SSRF via opaque_ref | Unchanged MEDIA contract — never fetch/dereference |
| Secrets in envelopes | No tokens/secrets in media_apply payload fields |
| Consent MCP write | Consent grant/revoke remain non-MCP-write (MEDIA KN-MD-3) |
| Service-token self-apply | Existing SEC-SEAM-1 refusals; no weakening |
SM-C12 — Citation readiness + hosted approve/apply ordering honesty
Every freeze-review / build-verification finding against this artifact MUST cite file+line. Uncited findings are invalid.
Ordering: Hosted canister approve commits before the gateway media hook runs
(G1 parity). Therefore precheck/apply failure after successful approve yields
approved proposal + no media effect with honest media_index_applied: false.
Auto MUST NOT claim atomic approve+apply on hosted. Self-hosted keeps
precheck-before-commit (G9) unchanged.
Ops recovery: re-calling bridge apply-approved when status === 'approved' is allowed
(SM-C4). The post-approve hook remains mandatory on every successful approve.
§SM.3 — HOW (Auto implementation sketch)
Ordered for SEC-SEAM-MEDIA-b. No redesign beyond this list.
lib/attachments/media-hosted-proposal.mjs:mergeMediaFrontmatter,normalizeCanisterProposalForMediaPrecheck,createMediaProposalOnCanister,fetchCanisterProposalForMedia,applyApprovedMediaProposalFromCanister(SM-C2–C5).hub/bridge/media-routes.mjs(new): register propose/consent/list/get/apply-approved; wrap mutates with media blob sync (SM-C3, SM-C4, SM-C6, SM-C7).- Blob module: extend
EXTERNAL_PROTOCOL_BLOB_FILESor add media blob helper + merge tests (SM-C6). hub/gateway/media-approve-hosted.mjs:maybeApplyHostedMediaAfterApprove+mergeMediaApplyIntoApproveResponse(SM-C1).hub/gateway/server.mjs: import hook; call after capture in approve success block; add attachment proxies with safe ordering (SM-C7).lib/hub-proposal-personal-self-apply.mjs: add normalize condition toisSeamSurfaceProposalsame commit as hook (SM-C2).- media_attach hosted note I/O: temp-stage or noteIO adapter + propose-time
media_pointerstamp (SM-C5); canister notes surface only. docs/openapi.yaml: attachment write/apply/consent hosted shapes with the routes.docs/PROPOSAL-LIFECYCLE.md: SM-C9 wording.- Tests:
test/sec-seam-media-hosted.test.mjs— §SM.4. - Governance: ROADMAP + OVERSEER-HANDOVER together; feature-branch Muse commit.
- Do not flip envs; do not touch P6 secrets.
§SM.4 — Seven-tier test matrix (SEC-SEAM-MEDIA-b)
File: prefer test/sec-seam-media-hosted.test.mjs (may split if size demands).
| Tier | Must prove |
|---|---|
| 1 unit | mergeMediaApplyIntoApproveResponse success/failure/null; normalize true for each recognition arm + false for notes/task; normalize sets source: media; pointer stamp preferred over vault walk |
| 2 integration | Bridge apply-approved with mock canister media_external_link → external-ref upsert visible via attachment list on same dataDir; blob persist called or store file updated |
| 3 e2e | Live Express gateway + mock bridge + mock canister: admin approve of media proposal → media_index_applied: true and bridge apply invoked; non-media approve → no media fields; gateway proxies hit bridge not canister catch-all |
| 4 stress | ≥50 sequential media apply-approved calls complete without unbounded handle growth; last external-link still listable |
| 5 data-integrity | Second apply-approved after external-link → MEDIA_LINEAGE_CONFLICT or explicit idempotent success without duplicate ref rows; consent expiry still refuses precheck |
| 6 performance | Single apply-approved + list p95 budget on fixture (document bound; no real canister network) |
| 7 security | (a) Correspondence: hook trigger ≡ S3.1 media normalize condition (source-scan + behavioral); (b) apply-approved with status: proposed → 409; (c) no new hand-written seam kind/intent list introduced; (d) opaque_ref never fetched (no http(s) client call in media apply path); (e) gates-off propose still 403 |
Existing test/sec-seam-1-session-bound-identity.test.mjs MUST remain green (re-run in BV).
§SM.5 — Definition of Done (SEC-SEAM-MEDIA-b)
- SM-C1–C12 implemented exactly
- Freeze-review
passon this artifact (prerequisite — this Thinking step) - Seven-tier tests green
/build-verification-review→passbefore ROADMAP → DONE- No secrets committed; no posture/env flip; no P6 R4–R5
docs/ROADMAP.md+docs/OVERSEER-HANDOVER.mdupdated together (SD-17)- Feature-branch Muse commit; merge remains Tier 3 / SD-21 land path
§SM.6 — Downstream paste (SEC-SEAM-MEDIA-b Auto) — after freeze-review pass
Step: SEC-SEAM-MEDIA-b
Model: Auto
Authority: knowtation
Frozen: docs/SEC-SEAM-MEDIA-FREEZE.md (frozen: true; review_stamp pass)
Implement SM-C1–C12 exactly:
- hub/gateway/media-approve-hosted.mjs + wire after capture in approve success block
- lib/attachments/media-hosted-proposal.mjs (normalize + create + apply-from-canister)
- isSeamSurfaceProposal gains normalizeCanisterProposalForMediaPrecheck in SAME change (S3.0)
- bridge media routes: propose/consent/list/get/apply-approved + blob sync
- gateway proxies before canister catch-all
- media_attach hosted note RMW via canister + propose-time media_pointer stamp
- PROPOSAL-LIFECYCLE honesty; seven-tier test/sec-seam-media-hosted.test.mjs
- /build-verification-review → pass; governance sync; Muse feature-branch commit
- No MEDIA_* / SCOOLING_MEDIA_* flips; no SESSION_SECRET writes; no P6 R4-R5; SD-14