fix(13): make D-13 track independence real in the wave graph

Plan-checker revision iteration 1 — 1 blocker + 2 warnings.

BLOCKER (context_compliance, D-13): the music track blocked phase
completion despite being locked as non-blocking. 13-15 depended on
13-11, which chains back through 13-07 to 13-04, so the phase could
not close without the entire music chain. Took the checker's option
(b): 13-15 depends_on is now ["13-06","13-09","13-14"] — 13-06 added
so the content-grid check stays a real gate, 13-11 dropped so no path
reaches 13-04/13-07/13-11. UAT step 7 is now content-only and blocking;
new step 7b is the music view as record-and-defer, the same shape step
10 already used for Routstr. Verified: 13-15's transitive closure
contains no music plan.

WARNING (scope_reduction): T-13-32 claimed the filebrowser-client.ts
JWT-in-query-string leak was "fixed" while only guaranteeing it was not
propagated. Now actually fixed — streamUrl returns a query-free
same-origin URL and relies on the path=/ cookie login() already sets;
filebrowser-client.ts and a new regression test are in 13-06's
files_modified. T-13-32 is scoped to new code; new T-13-39 owns the
pre-existing leak and names the residual (the JWT is still 24h, now
confined to the cookie jar).

WARNING (verification_derivation): the edge-probe reconciliation did
not match the files. Corrected in 13-VALIDATION.md — 10 probe findings
vs 9 edge entries kept apart, 13-07's 3 truths retagged as authored
rather than probe-surfaced. No truths deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 11:12:06 -04:00
co-authored by Claude Opus 5
parent 203966030b
commit 60625499ae
7 changed files with 177 additions and 37 deletions
+5 -1
View File
@@ -386,4 +386,8 @@ Plans:
- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06)
**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan
on the control or content track depends on any music plan.
on the control or content track depends on any music plan, **and neither does the phase-closing
gate**. 13-15 depends on 13-06, 13-09 and 13-14 only, so there is no path from it to 13-04,
13-07 or 13-11: if the music track slips or is deferred, 13-15 records that at its step 7b and
the control and content work still closes and ships. 13-11 is therefore a terminal plan of the
phase rather than a gate on it.
@@ -112,8 +112,15 @@ Goes red if a future phase reintroduces the mesh-only assumption.
</assumption_delta_decision>
<flagged_assumptions>
None in this plan. (The four `unclassified` edge-probe rows are surfaced in 13-05, 13-09 and
13-15, where their requirements live.)
None in this plan.
**Edge-probe accounting for AIUI-01.** Its probe resolved `covered` and produced two findings,
both here: the pending-confirmation lifecycle truth tagged `(edge: AIUI-01 concurrency)` above,
and the two-tab nonce finding carried as a `verification: backstop` scalar rather than a plain
truth. The four probes that returned `unclassified` belong to other requirements and are
surfaced where those requirements live — AIUI-02 in 13-05, AIUI-04 and AIUI-05 in 13-09, AIUI-06
in 13-15. Six requirements probed, two `covered`, four `unclassified`, nothing dropped; the full
reconciliation with its counts is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
</flagged_assumptions>
<artifacts_this_phase_produces>
@@ -7,6 +7,8 @@ depends_on: ["13-01"]
files_modified:
- neode-ui/src/composables/archyContentAdapter.ts
- neode-ui/src/composables/__tests__/archyContentAdapter.test.ts
- neode-ui/src/api/filebrowser-client.ts
- neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts
- neode-ui/src/services/contextBroker.ts
- neode-ui/src/types/aiui-protocol.ts
- /home/archipelago/Projects/AIUI/packages/app/src/composables/useArchy.ts
@@ -23,7 +25,8 @@ must_haves:
- "An empty content list renders the grid's empty state, not a spinner and not an error; a single item renders a one-card grid; an item with a null or absent description maps to an empty string, never the literal 'null' or 'undefined' (edge: AIUI-03 empty)"
- "Content ordering is added_at descending with id ascending as the deterministic tiebreak, so items with equal timestamps come back in the same order on every call (edge: AIUI-03 ordering)"
- "A content refresh arriving while an earlier one is still in flight is discarded by a request-id guard, so the grids never flip back to older data (edge: AIUI-03 concurrency)"
- "No new streaming URL in this phase carries a credential in its query string — the filebrowser-client.ts streamUrl JWT-in-URL leak is not propagated"
- "No new streaming URL in this phase carries a credential in its query string — the leak is not propagated into the adapter"
- "The pre-existing leak is actually closed, not merely avoided: filebrowser-client.ts's streamUrl returns a bare same-origin raw-file URL with no query component, and playback still works because the same-origin filebrowser cookie already travels on media subresource requests"
artifacts:
- path: "neode-ui/src/composables/archyContentAdapter.ts"
provides: "ContentItem -> Film/Song/Podcast mapping; there is no shape overlap, so this is hand-written mapping logic"
@@ -31,6 +34,9 @@ must_haves:
- path: "neode-ui/src/composables/__tests__/archyContentAdapter.test.ts"
provides: "Fixture-pinned mapping including the adjacency, empty, ordering and concurrency edges"
min_lines: 80
- path: "neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts"
provides: "Regression pin that streamUrl emits no query component, so the JWT-in-URL leak cannot come back"
contains: "streamUrl"
key_links:
- from: "neode-ui/src/services/contextBroker.ts"
to: "neode-ui/src/composables/archyContentAdapter.ts"
@@ -65,12 +71,27 @@ a node serves, so TMDB posters, web search and RSS stay 404 on a node. Only the
replaces gets a production answer; the rest stays explicitly deferred, and the plan says so
rather than implying otherwise.
Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker, and
`setArchyContent` in AIUI.
This plan also closes the one credential-in-URL leak CONTEXT.md names by hand:
`filebrowser-client.ts`'s `streamUrl` puts the filebrowser JWT in the query string, where it
reaches browser history, `Referer` headers and access logs. CONTEXT.md calls it "the known leak
to **fix** rather than propagate", so not reproducing it in new code is only half the
instruction. The fix is small because the credential there is redundant: `login()` already sets
that JWT as a `path=/` cookie on the page's own origin, and the browser attaches it to the
same-origin media request without being asked.
Output: `archyContentAdapter.ts`, a `content:push` channel on the existing broker,
`setArchyContent` in AIUI, and a query-free `streamUrl`.
</objective>
<flagged_assumptions>
None in this plan. (AIUI-03's four edge probes are all resolved as `covered` truths above.)
None in this plan.
**Edge-probe accounting for AIUI-03.** The probe surfaced **four** edges — adjacency, empty,
ordering, concurrency — and all four are discharged here as covered truths tagged
`(edge: AIUI-03 …)`. 13-07 carries three further truths with an AIUI-03 edge tag; those are
**planner-authored** re-applications of the same edge kinds to the persisted music index, marked
`— authored, not probe-surfaced` so the phase does not count one four-finding probe as seven.
The reconciliation is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
</flagged_assumptions>
<artifacts_this_phase_produces>
@@ -88,6 +109,10 @@ Symbols created by **this plan**:
- `composables/useArchy.ts`: `requestArchyContent`
- `composables/useContentPanel.ts`: `setArchyContent`, `archyContentActive` (ref)
Changed, not created: `neode-ui/src/api/filebrowser-client.ts``streamUrl`'s body only. No new
export, no signature change; it still returns `Promise<string>`, so every existing call site is
untouched.
Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid.vue`,
`NewsGrid.vue`, `ContentGridView.vue`, and every `Film`/`Song`/`Podcast` type in
`packages/core/src/types/content.ts`.
@@ -111,7 +136,7 @@ Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid
<task type="auto" tdd="true">
<name>Task 1: The adapter — hand-written mapping, fixture-pinned, edges decided</name>
<files>neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts</files>
<files>neode-ui/src/composables/archyContentAdapter.ts, neode-ui/src/composables/__tests__/archyContentAdapter.test.ts, neode-ui/src/api/filebrowser-client.ts, neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts</files>
<behavior>
- A video-mime `ContentItem` becomes a `Film` with `id` carried through, `title` derived from `filename` minus its extension, and exactly one entry in `sources[]` describing where it came from.
- An audio-mime `ContentItem` becomes a `Song`; an image or document mime becomes neither and is excluded from all three buckets rather than mis-typed.
@@ -121,12 +146,16 @@ Unchanged by design and therefore **not** new symbols: `FilmGrid.vue`, `SongGrid
- An empty input array produces empty `films`/`songs`/`podcasts` arrays — not `undefined`, not a thrown error.
- A `null`/absent `description` maps to `''`; a `null` `added_at` sorts last rather than crashing the comparator.
- Sorting is `added_at` descending, `id` ascending on ties — calling the adapter twice on the same input in a different array order yields identical output order.
- `fileBrowserClient.streamUrl('/Music/x.m4a')` resolves to a same-origin raw-file URL carrying no query component and no credential anywhere in the string — the returned value contains no `?`, and does not contain the cookie's value.
- `streamUrl` still awaits authentication before returning, so the cookie the media request depends on is guaranteed to be set by the time the caller assigns the URL to a media element.
- `sanitizePath` traversal handling is unchanged by the fix — a path containing `..` is still resolved and never escapes root.
</behavior>
<read_first>
- `/home/archipelago/Projects/AIUI/packages/core/src/types/content.ts` lines 7-70 — the exact target shapes: `Film` (line 7), `FilmSource` (23), `SongSource` (37), `Song` (44), `Podcast` (63). **This file is read, never modified** — D-12 keeps AIUI's design exactly.
- `core/archipelago/src/content_server.rs``ContentItem` and `AccessControl` (`Free | PeersOnly | Paid`), the source shape being mapped from.
- `core/archipelago/src/api/rpc/content.rs``content.list-mine`, `content.browse-peer`, `content.owned-list`, `content.preview-peer`, and the MIME auto-filing logic around line 668 (the classification precedent to stay consistent with).
- `neode-ui/src/api/filebrowser-client.ts` lines 172-176 — `streamUrl`'s `?auth=${token}` JWT-in-query-string. **This is the known leak to fix rather than propagate**; read it so you recognise the pattern and do not reproduce it.
- `neode-ui/src/api/filebrowser-client.ts` in full — CONTEXT.md names this "the known leak to fix rather than propagate", and **this task fixes it**, so read the whole client, not just the leaking function. The four facts that make the fix small and safe: `login()` (lines 55-83) sets the filebrowser JWT as a **cookie** with `path=/` and `SameSite=Lax` on the page's own origin; `baseUrl` (line 43) is `window.location.origin + '/app/filebrowser'`, so a media element's request for it is **same-origin**; a same-origin subresource request carries that cookie automatically and `SameSite=Lax` does not restrict same-site subresources; and filebrowser's own auth reads the `auth` cookie, which is why its own web UI works without a query parameter. The credential in the query string is therefore redundant, not load-bearing.
- `neode-ui/src/stores/cloud.ts` lines 117-119 and `neode-ui/src/components/cloud/MediaLightbox.vue` lines 138 and 202-203 — the call sites. They consume a URL string and are unaffected by dropping its query component; confirm that before changing anything.
- `neode-ui/src/composables/__tests__/useFileType.test.ts` — the in-repo convention for a fixture-driven pure-function Vitest suite.
- `13-RESEARCH.md` Pitfall 4 and Pitfall 5.
</read_first>
@@ -137,7 +166,13 @@ Create `neode-ui/src/composables/archyContentAdapter.ts` exporting `adaptContent
`adaptToFilm`/`adaptToSong`/`adaptToPodcast` carry `id` through unchanged as the card key (this is what makes the adjacency case correct: two peers sharing a byte-identical file are two rows, because they are two things the operator can act on separately). Derive `title` from `filename` with the extension stripped. Map `description ?? ''`. Build exactly one `sources[]` entry per item, with a `type` value that distinguishes this node's own file from a peer's file from IndeeHub — pin those three literal values in the test so a later refactor cannot quietly change what a grid badge means.
For playback URLs: **do not build any URL containing a credential in its query string.** Own-node media resolves through the existing content endpoints (`/content/<id>`), peer media through the existing Rust Range-streaming proxy (`/api/peer-content/<onion>/<id>`) — both of which already carry the page's session. Where a bare `<audio>`/`<video src>` is unavoidable and a token is genuinely required, the URL must be minted per-resource and single-use rather than reusing a general session token. Add an assertion in the test that no produced URL matches `[?&](auth|token)=`.
For playback URLs: **do not build any URL containing a credential in its query string.** Own-node media resolves through the existing content endpoints (`/content/<id>`), peer media through the existing Rust Range-streaming proxy (`/api/peer-content/<onion>/<id>`) — both of which already carry the page's session. Where a bare `<audio>`/`<video src>` is unavoidable and a token is genuinely required, the URL must be minted per-resource and single-use rather than reusing a general session token. Add a test assertion that no adapter-produced URL carries a credential as a query parameter.
**Then close the pre-existing leak rather than merely routing around it.** CONTEXT.md names `filebrowser-client.ts`'s `streamUrl` as the known leak "to fix rather than propagate", and a phase that only avoids reproducing it has not fixed it. In `filebrowser-client.ts`, change `streamUrl` to keep its `ensureAuth()` await and its `sanitizePath` call, and return the raw-file URL with **no query component appended at all** — drop the `getAuthCookie()` read and the credential interpolation entirely. The cookie that request needs is already set on the page's origin at `path=/` by `login()`, and the browser attaches it to the same-origin media subresource request by itself; that is the same mechanism filebrowser's own UI relies on. Leave `headers()`, `authedFetch` and `fetchBlobUrl` alone — they authenticate by `X-Auth` header and were never leaking.
Write `neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts` as the regression pin: stub `document.cookie` and the `app.filebrowser-token` RPC, call `streamUrl`, and assert the result has no query component, contains none of the token's characters, and still points at the same-origin raw-file path for the sanitized input path. Include a traversal case so the fix cannot quietly change path handling.
Two things to record honestly in the summary. This removes the credential from browser history, `Referer` headers and access logs — it does **not** make the cookie itself short-lived or single-use; the 24-hour filebrowser JWT remains a 24-hour JWT, now confined to the cookie jar. And if playback regresses on the node — the one way this fix can fail is a deployment where filebrowser does not honour the cookie on its raw endpoint — do not restore the query parameter; report it and stop, because restoring it reopens exactly the leak this task exists to close.
For `access: 'Paid'`: map `price_sats` and set a locked flag; do not emit a playable source. D-14 routes unlock through the existing invoice / `X-Payment-Token` / Range-streaming path — no new payment rail, and none is introduced here.
@@ -147,19 +182,24 @@ Write the tests FIRST in `archyContentAdapter.test.ts`, one per `<behavior>` bul
</action>
<verify>
<automated>cd neode-ui &amp;&amp; npx vitest run src/composables/__tests__/archyContentAdapter.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vitest run src/api/__tests__/filebrowserStreamUrl.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vitest run src/components/__tests__/MediaLightboxPip.test.ts</automated>
<automated>cd neode-ui &amp;&amp; npx vue-tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts` exits 0 with a test per `<behavior>` bullet
- `grep -q 'export function adaptContentItems' neode-ui/src/composables/archyContentAdapter.ts`
- `grep -ciE 'm4a|aac|opus|wma' neode-ui/src/composables/archyContentAdapter.ts` is ≥ 4
- `grep -cE '[?&](auth|token)=' neode-ui/src/composables/archyContentAdapter.ts` returns 0 — the `filebrowser-client.ts` leak is not propagated
- The test file contains an assertion that no adapter-produced URL matches `[?&](auth|token)=`
- `grep -vE '^\s*(//|\*|/\*)' neode-ui/src/composables/archyContentAdapter.ts | grep -cE '[?&](auth|token)='` returns 0 — no credential-bearing URL is produced by the adapter (comment lines stripped first, so prose in the file cannot self-invalidate the gate)
- The test file contains an assertion that no adapter-produced URL carries a credential query parameter
- `cd neode-ui && npx vitest run src/api/__tests__/filebrowserStreamUrl.test.ts` exits 0 — the pre-existing leak is closed and pinned
- `grep -vE '^\s*(//|\*|/\*)' neode-ui/src/api/filebrowser-client.ts | grep -cF 'raw${safePath}?'` returns 0 — `streamUrl` appends no query component
- `cd neode-ui && npx vitest run src/components/__tests__/MediaLightboxPip.test.ts` exits 0 — the lightbox's `streamUrl` consumer did not regress
- `git -C /home/archipelago/Projects/AIUI diff --exit-code -- packages/core/src/types/content.ts packages/app/src/components/content/FilmGrid.vue packages/app/src/components/content/SongGrid.vue` exits 0 — D-12's "props unchanged" held
- `cd neode-ui && npx vue-tsc --noEmit` exits 0
</acceptance_criteria>
<reversibility rating="costly">D-12's grid-source swap is rated costly in CONTEXT.md — the grids stay prop-driven and the source behind them is swappable, but every consumer is written against this mapping's field semantics. Flagged, not gated.</reversibility>
<done>Real `ContentItem` fixtures produce grid-ready `Film`/`Song`/`Podcast` records with stable ordering, correct empty/adjacency behaviour, no credential-bearing URLs, and no change to any AIUI grid component.</done>
<done>Real `ContentItem` fixtures produce grid-ready `Film`/`Song`/`Podcast` records with stable ordering, correct empty/adjacency behaviour, no credential-bearing URLs, and no change to any AIUI grid component — and `filebrowser-client.ts`'s `streamUrl` returns a query-free same-origin URL, so the leak CONTEXT.md named is closed rather than merely unrepeated.</done>
</task>
<task type="auto">
@@ -257,7 +297,8 @@ Commit and push on `development`, staging explicitly by path.
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-13-32 | Information Disclosure | A credential in a media URL query string | high | mitigate | The adapter builds no `?auth=`/`?token=` URL; own media goes through session-carrying content endpoints and peer media through the existing Rust Range proxy. Asserted by a grep gate and a test assertion. This is the `filebrowser-client.ts` leak, fixed rather than propagated |
| T-13-32 | Information Disclosure | A credential in a media URL query string built by **new** code (the adapter) | high | mitigate | The adapter builds no credential-bearing URL; own media goes through session-carrying content endpoints and peer media through the existing Rust Range proxy. Asserted by a comment-filtered grep gate and a test assertion. Scope of this row is the new code only — the pre-existing leak is T-13-39 |
| T-13-39 | Information Disclosure | The **pre-existing** leak: `filebrowser-client.ts`'s `streamUrl` puts the filebrowser JWT in the query string, so it reaches browser history, `Referer` headers and any access log on the path | high | mitigate | `streamUrl` is changed in this plan's Task 1 to return a query-free same-origin URL and rely on the `path=/` cookie `login()` already sets — `filebrowser-client.ts` is in `files_modified` and `filebrowserStreamUrl.test.ts` pins it. **Residual, stated rather than implied:** the JWT is still a 24-hour token, now confined to the cookie jar; making it short-lived or per-resource is a separate change this phase does not make |
| T-13-33 | Information Disclosure | Content pushed to the iframe without a grant | high | mitigate | `handleContentRequest` checks `media`/`files` through the existing permissions store before any RPC call |
| T-13-34 | Tampering | Iframe names its own RPC method or params | high | mitigate | The iframe supplies only a `kind`/`scope` enum; the broker chooses the method. Asserted by the "iframe never names an RPC method" grep |
| T-13-35 | Elevation of Privilege | Paid content playable without unlock | high | mitigate | `access: 'Paid'` maps to a locked card with no playable source; unlock stays on the existing invoice / `X-Payment-Token` path (D-14). No new payment rail |
@@ -271,13 +312,15 @@ Commit and push on `development`, staging explicitly by path.
- `cd neode-ui && npx vitest run src/composables/__tests__/archyContentAdapter.test.ts && npx vitest run src/services/__tests__/contextBroker.test.ts && npx vitest run src/views/__tests__/chatAiuiEmbed.test.ts` all green
- `cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run && npx vue-tsc --noEmit` green
- `git -C /home/archipelago/Projects/AIUI diff --exit-code HEAD~1 -- packages/app/src/components/content/ packages/core/src/types/content.ts` exits 0
- No adapter-produced URL matches `[?&](auth|token)=`
- No adapter-produced URL carries a credential query parameter, and `filebrowserStreamUrl.test.ts` is green
</verification>
<success_criteria>
AIUI's existing grids show the node's real content, with no grid component or content type
changed; the mapping is pinned by fixtures at its adjacency, empty, ordering and concurrency
edges; and the phase gains no new credential-in-URL leak and no new payment rail.
edges; the phase gains no new credential-in-URL leak and no new payment rail; and the one
credential-in-URL leak that already existed is closed at its source, with its remaining
long-lived-token residual named rather than glossed.
</success_criteria>
<output>
@@ -17,9 +17,9 @@ must_haves:
- "The node has a real music library — albums, artists and tracks derived from extracted tags, persisted under data_dir, not a MIME filter over a folder listing (D-13)"
- "The index stays fresh: a file added, changed or removed since the last scan is reflected without a full rebuild, and a full reindex is available on demand"
- "An index written by a newer MUSIC_SCHEMA_VERSION is refused and rebuilt rather than misread"
- "A concurrent read during a reindex returns a consistent snapshot, never a partially-written index (edge: AIUI-03 concurrency)"
- "Album and track ordering is deterministic and stable across repeated calls, with a defined tiebreak when sort keys are equal (edge: AIUI-03 ordering)"
- "An empty library returns empty arrays with a scanned-at timestamp, not an error and not a null (edge: AIUI-03 empty)"
- "A concurrent read during a reindex returns a consistent snapshot, never a partially-written index (edge: AIUI-03 concurrency — authored, not probe-surfaced)"
- "Album and track ordering is deterministic and stable across repeated calls, with a defined tiebreak when sort keys are equal (edge: AIUI-03 ordering — authored, not probe-surfaced)"
- "An empty library returns empty arrays with a scanned-at timestamp, not an error and not a null (edge: AIUI-03 empty — authored, not probe-surfaced)"
- "The indexer never reads outside the configured media roots"
artifacts:
- path: "core/archipelago/src/music/index.rs"
@@ -62,6 +62,15 @@ Output: `music/index.rs`, `music/mod.rs` completed, and the `music.*` RPC surfac
<flagged_assumptions>
None in this plan.
**Edge-tag provenance.** The three truths above tagged `(edge: AIUI-03 … — authored, not
probe-surfaced)` are **not** deterministic-probe output. The AIUI-03 probe surfaced four edges
and all four are discharged in 13-06 as covered truths. These three re-apply the same edge kinds
— concurrency, ordering, empty — to a different subject (the on-disk music index rather than the
in-browser content adapter), because a persisted index has its own versions of them that
13-06's tests cannot reach. They are planner-authored coverage, and the tag says so, so the
phase's edge accounting is not double-counting one probe as seven findings. The full
reconciliation is in `13-VALIDATION.md` § Edge-Probe Reconciliation.
</flagged_assumptions>
<artifacts_this_phase_produces>
@@ -17,7 +17,7 @@ must_haves:
- "AIUI's SongGrid shows the node's real music library — albums, artists and tracks from the index, not a MIME-filtered folder listing (D-13, D-12)"
- "An .m4a, .aac, .opus or .wma file shared from the cloud view gets a real audio MIME type, routes to the global bottom-bar player, and auto-files to Music instead of Documents"
- "Audio opens in the global bottom-bar player and never in the lightbox — the rule enforced across five existing call sites is not broken by the new path"
- "The music track reached the UI without any control-track or content-track plan depending on a music plan (D-13)"
- "The music track reached the UI without any control-track or content-track plan depending on a music plan, and without the phase-closing gate 13-15 depending on one either (D-13)"
artifacts:
- path: "neode-ui/src/composables/archyContentAdapter.ts"
provides: "music.* records mapped onto AIUI's Song/Album shape, alongside the existing ContentItem mapping"
@@ -41,7 +41,14 @@ D-13 is explicit that the music library "lands as its own wave of plans inside P
blocking the rest — peer files, movies and conversational control ship on their own track and
the library lights up `SongGrid` when ready." This is that plan. Its `depends_on` points at the
music indexer and the content adapter; **no plan on the control or content track depends on any
music plan**, so the track independence D-13 requires holds literally in the wave graph.
music plan, and neither does the phase-closing gate 13-15**, so the track independence D-13
requires holds literally in the wave graph rather than only in prose.
That makes this a **terminal** plan: nothing lists it in `depends_on`, by design. It is not
orphaned — it owns AIUI-03's `SongGrid` and share-MIME deliverables, it lands at wave 4 well
ahead of the wave-8 gate, and 13-15 step 7b reads its summary as a best-effort input and records
the result. But if it slips, is red, or is deferred, 13-15 records that and the phase closes on
the control and content tracks anyway. That is the whole point of D-13.
The second half is a verified, directly-relevant landmine: `neode-ui/src/components/cloud/ShareModal.vue`
line 358 maps `mp3`, `flac`, `ogg` and `wav` and omits `m4a`, `aac`, `opus` and `wma`. Those four
@@ -3,7 +3,7 @@ phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 15
type: execute
wave: 8
depends_on: ["13-09", "13-11", "13-14"]
depends_on: ["13-06", "13-09", "13-14"]
files_modified:
- .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-VALIDATION.md
- .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-UAT.md
@@ -16,6 +16,7 @@ must_haves:
- "Every row of 13-VALIDATION.md's Per-Task Verification Map is owned by a named task in a named plan and has a recorded status"
- "The deployed surface is checked, not only the source: the model proxies are closed and the shipped bundle is the one that was built"
- "Every manual-only verification listed in 13-VALIDATION.md has been performed and its result recorded"
- "This gate closes on the control and content tracks alone: its depends_on contains no music plan, and no music-track outcome can hold the phase open (D-13)"
artifacts:
- path: ".planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-UAT.md"
provides: "The on-device acceptance record: what was exercised, on what hardware, at what viewport, with what result"
@@ -50,6 +51,17 @@ This plan also discharges `13-VALIDATION.md`, whose Per-Task Verification Map st
Task ID / Plan / Wave / Threat Ref columns by design — the planner left them fillable and
execution fills them.
**What this gate does and does not gate on (D-13).** D-13 locks the music library as its own
track inside Phase 13 that does **not** block the rest: "peer files, movies and conversational
control ship on their own track and the library lights up `SongGrid` when ready." So this gate
depends on the control track (13-14, and through it 13-13 → 13-12 → 13-10 → 13-08 → 13-05 →
13-01), the delivery track (13-09 → 13-02) and the content track (13-06) — and on **no** music
plan. The music chain (13-04 → 13-07 → 13-11) lands at wave 4 and, if it is ready, its result is
recorded here as a bonus pass; if it slipped, is red, or was deferred, that is recorded and the
phase still closes. This is a real property of the wave graph, not a comment: there is no path
from this plan's `depends_on` to 13-04, 13-07 or 13-11. Step 7b below is the non-blocking music
step and takes exactly the record-and-defer shape step 10 already uses for Routstr.
Output: a completed `13-VALIDATION.md` and a `13-UAT.md` acceptance record.
</objective>
@@ -81,9 +93,13 @@ repository.
@CLAUDE.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-VALIDATION.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-06-SUMMARY.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-09-SUMMARY.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-11-SUMMARY.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-14-SUMMARY.md
Deliberately **not** auto-included: `13-11-SUMMARY.md`. It may not exist when this plan runs, and
this gate must not fail to load because the music track has not landed. Task 2 reads it only if
it is present.
</context>
<tasks>
@@ -139,12 +155,13 @@ Set `nyquist_compliant` in the frontmatter to `true` only if every row has an au
- `CLAUDE.md` — deploy to the dev pair before any OTA; verify on the real node before any tag; the frontend-build verify rule; and the node-side verify rule that `assets/` is a graveyard so live chunks must be resolved via the service worker manifest and fetched over HTTP.
- The project memory notes for archi-dev-box's address and credentials, and for the known mobile gotcha that `.local` https does not resolve on Android (no mDNS) — reach the node by IP or Tailscale name on the handheld, not by `.local`.
- `.planning/phases/13-.../13-09-SUMMARY.md` — the deploy and verify scripts to use, and the CSP that landed.
- `.planning/phases/13-.../13-11-SUMMARY.md`**only if the file exists.** This is the music track's landing summary and it is a best-effort input to step 7b, never a gate. If it is absent, the music track has not landed; go to step 7b's defer branch and do not wait for it.
</read_first>
<what-built>
The whole phase, deployed to archi-dev-box: the node-side assistant with three backends, the
curated tool registry, the confirm gate in trusted chrome, the untrusted-content boundary, the
egress guardrails, the content grids fed from real node data, the music library, the closed
model proxies and the verified delivery path.
egress guardrails, the content grids fed from real node data, the closed model proxies and the
verified delivery path — plus, if its independent track landed in time, the music library.
</what-built>
<how-to-verify>
Deploy first: `bash scripts/build-aiui.sh`, build the frontend with `cd neode-ui && npm run build`
@@ -166,10 +183,21 @@ have not confirmed by fetching bytes.
Ask again and approve — that container restarts.
6. Ask for something outside the ceiling: a wallet spend, the seed phrase, a factory reset. Each
is refused plainly, redirected to the real UI path, and **not** described as done.
7. Open a content grid. Real peer/owned files render. Open the music view — real albums and
tracks. Play a track: it plays in the bottom bar and does **not** open the lightbox.
8. Share an `.m4a` from the cloud view. It gets an audio type, plays in the bottom bar, and
files under Music.
7. Open a content grid. Real peer/owned files render — not fixtures, not model-invented rows.
Play a media file from that grid: audio plays in the bottom bar and does **not** open the
lightbox. **Blocking** — this is 13-06's content track and it is a gate.
7b. **Non-blocking, music track (D-13).** If `13-11-SUMMARY.md` exists, open the music view:
real albums and tracks from the index, and a played track goes to the bottom bar, not the
lightbox. Then share an `.m4a` from the cloud view and confirm it gets an audio type, plays
in the bottom bar, and files under Music rather than Documents. If the music track has not
landed, is red, or was deferred, **record that in `13-UAT.md` and move on** — exactly as
step 10 does for Routstr. A missing or failing music view is recorded as a known gap and
does **not** block this phase; D-13 locked the library as an independent track precisely so
that the control and content work can ship without it.
8. Share a non-audio file (a video and a document) from the cloud view and confirm each still
gets its correct type and files where it always did. The share path's MIME map is edited by
the music track, so this is the regression check that the other types were not disturbed —
and it is meaningful whether or not that edit has landed yet.
9. In the AIUI frame's devtools console, POST to the RPC endpoint. It is CSP-blocked. From the
top-level frame, the same call succeeds.
10. If Routstr shipped: set a small prepaid allowance, force the Routstr leg, and confirm a real
@@ -181,7 +209,8 @@ or Tailscale name, not `.local`):
11. AIUI renders in the embedded iframe at phone width without horizontal scroll.
12. The confirm dialog covers the full viewport on the handheld and its buttons are tappable
without zooming.
13. The content grid and the music grid are usable at phone width.
13. The content grid is usable at phone width. If the music track landed, the music grid is too —
if it did not, record that and carry on, per step 7b.
14. The bottom-bar audio player is reachable and does not collide with the mobile tab bar.
15. The on-screen keyboard does not push the chat input off-screen or under the tab bar.
@@ -190,15 +219,17 @@ address, the build marker, and screenshots for steps 5, 9, 11 and 12.
</how-to-verify>
<acceptance_criteria>
- `bash scripts/verify-aiui-deploy.sh <node> "<marker>"` exits 0 and `bash tests/production-quality/aiui-proxy-closed.sh <node>` exits 0, both recorded with their output
- `13-UAT.md` exists with a row per numbered step above, each marked pass or fail with an observation not a bare tick
- `13-UAT.md` exists with a row per numbered step above — the fifteen numbered steps plus 7b — each marked pass, fail or deferred with an observation, not a bare tick
- Steps 2, 3, 5, 6, 7 and 9 all pass; any failure among them blocks the phase rather than being recorded as a known issue
- Steps 7b and 10 are the only two steps whose failure or absence does **not** block: each records either a real result or a named deferral, and neither may be left silent
- `13-UAT.md` states in one line that the phase closed on the control and content tracks, and gives 7b's music-track outcome as pass, gap or deferred — so a reader can tell which of the two tracks this sign-off covers
- Screenshots for steps 5, 9, 11 and 12 are referenced in `13-UAT.md`
- The hardware, browser and viewport used for the mobile pass are named, including the real handheld's model
- Step 10 records either a successful paid request with a hard stop at the ceiling, or 13-13's recorded deferral — never silence
- `13-UAT.md` cross-references the four Manual-Only rows in `13-VALIDATION.md` and each of those rows is marked discharged
- Both planning documents are committed and pushed per `CLAUDE.md`
</acceptance_criteria>
<resume-signal>Type "approved" with the fifteen step results and the handheld model, or list which steps failed and what you saw.</resume-signal>
<resume-signal>Type "approved" with the fifteen numbered step results plus 7b's music-track outcome and the handheld model, or list which steps failed and what you saw.</resume-signal>
</task>
</tasks>
@@ -223,6 +254,7 @@ address, the build marker, and screenshots for steps 5, 9, 11 and 12.
| T-13-105 | Spoofing | The confirm dialog clipped or trapped by an ancestor transform at phone width, so the backdrop is not full-screen | medium | mitigate | Step 12 checks it on real hardware. This is the project's repeatedly-reinforced Teleport-to-body rule and its failure mode is a partially-obscured signing screen |
| T-13-106 | Denial of Service (financial) | Routstr's live behaviour untested, so the budget ceiling is only proven in unit tests | medium | mitigate | Step 10 either exercises a real paid request and a real exhaustion stop, or records 13-13's deferral. Silence is not an acceptable outcome |
| T-13-107 | Repudiation | A validation map marked compliant while rows remain undischarged | medium | mitigate | `nyquist_compliant` is set true only when every row is discharged; otherwise it stays false with the gap named |
| T-13-108 | Denial of Service (delivery) | The independent music track holding the control/content sign-off hostage, so shippable work cannot be signed off | medium | mitigate | D-13 enforced structurally: this plan's `depends_on` has no path to 13-04/13-07/13-11, and step 7b is record-and-defer rather than a gate. The wave graph, not a comment, is what makes the two tracks separable |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added; this plan changes no source in either repository. No install task, so no legitimacy checkpoint required |
</threat_model>
@@ -230,15 +262,21 @@ address, the build marker, and screenshots for steps 5, 9, 11 and 12.
- All three automated suites green: `cargo test --package archipelago` from `core/`, `vitest run` from `neode-ui/`, and `vitest run` from AIUI's `packages/app`
- `verify-aiui-deploy.sh` and `aiui-proxy-closed.sh` both exit 0 against archi-dev-box
- `grep -c TBD 13-VALIDATION.md` returns 0 and no row is `pending`
- `13-UAT.md` records all fifteen steps with observations and the four screenshots
- `13-UAT.md` records all fifteen numbered steps plus 7b with observations and the four screenshots
- This plan's `depends_on` names no music plan, and `13-UAT.md` states which track the sign-off covers
</verification>
<success_criteria>
The phase is done in the sense the phase itself demands: a typed request in the real embedded
AIUI on real hardware reaches a real node action and returns a real result; writes stop at a
dialog the iframe cannot touch; the ceiling holds; the grids and the library show real data;
the unauthenticated doors are shut; and all of it is recorded against a node, on desktop and on
a phone, rather than asserted from a test run.
dialog the iframe cannot touch; the ceiling holds; the content grids show real data; the
unauthenticated doors are shut; and all of it is recorded against a node, on desktop and on a
phone, rather than asserted from a test run.
The music library's state is recorded here, not required here. Per D-13 it is an independent
track: if it landed, 7b records it passing and the phase closes with the library lit; if it did
not, 7b records the gap and the phase closes anyway on the control and content tracks. Either
outcome is a valid close — an unrecorded one is not.
</success_criteria>
<output>
@@ -80,13 +80,45 @@ Requirement-level map seeded from research. **The planner fills Task ID / Plan /
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Embedded AIUI works in the real iframe | AIUI-06 | Real-device rendering in the actual embed context; `dev:mock` does not reproduce it | Load neode-ui Chat view on archi-dev-box, desktop **and** mobile viewport; exercise a read tool, a confirmed write, and a content grid |
| Embedded AIUI works in the real iframe | AIUI-06 | Real-device rendering in the actual embed context; `dev:mock` does not reproduce it | Load neode-ui Chat view on archi-dev-box, desktop **and** mobile viewport; exercise a read tool, a confirmed write, and a content grid. **Scope (D-13):** the control and content tracks are blocking here; the music view is 13-15 step 7b, recorded as pass, gap or deferred and never blocking |
| Frontend bundle actually shipped | AIUI-05 | Node `assets/` is a never-pruned graveyard — a disk grep reports "deployed" before the deploy | Resolve live chunks via `sw.js`, fetch over HTTP, grep the **fetched** bytes for the new string |
| Confirm dialog is un-spoofable by the iframe | AIUI-04 / D-11 | Anti-spoofing is a visual/trust property of the host chrome | Verify the dialog renders outside the iframe, Teleports to body, full-screen backdrop, text drawn from the node's description — not model-authored |
| Routstr pays a live request | D-04 / D-05 | Research confidence on the Routstr protocol is MEDIUM — cited from docs, never run against a live provider | Spike against a real provider before the integration is trusted; budget ceiling must hard-stop |
---
## Edge-Probe Reconciliation
The audit trail for the deterministic edge probe, counted against the plan files rather than
asserted. An earlier summary claimed "5 truths + 4 unclassified = 9, nothing dropped"; that
total was right by coincidence and wrong by composition, because it omitted the backstop scalar
and silently absorbed three planner-authored edges into the probe's own count. The real numbers:
| Line | Count | Where |
|------|-------|-------|
| Requirements probed | 6 | AIUI-01 … AIUI-06 |
| Probes resolved `covered` | 2 | AIUI-01, AIUI-03 |
| Probes returned `unclassified` — flagged, never auto-resolved and never auto-backstopped | 4 | AIUI-02 → 13-05 · AIUI-04 → 13-09 · AIUI-05 → 13-09 · AIUI-06 → 13-15 |
| Probe-surfaced findings authored as covered truths | 5 | 1 in 13-01 (AIUI-01 concurrency) · 4 in 13-06 (AIUI-03 adjacency, empty, ordering, concurrency) |
| Probe-surfaced findings authored as `verification: backstop` scalars | 1 | 13-01 — the two-tab confirmation-nonce case |
| **Probe findings total** | **6 covered + 4 unclassified = 10** | 5 truths + 1 backstop + 4 flagged |
| Planner-authored edge truths — **not** probe output | 3 | 13-07 — concurrency, ordering and empty re-applied to the persisted music index, tagged `— authored, not probe-surfaced` |
| **Edge-tagged truths across all plans** | **8** | 1 (13-01) + 4 (13-06) + 3 (13-07) |
| **Edge entries across all plans, incl. the backstop scalar** | **9** | the 8 above + 13-01's backstop |
Two numbers are easy to conflate and are deliberately kept apart here: **10** probe findings
(what the probe produced) and **9** edge entries in the plan files (what was written, including
three authored edges the probe never surfaced and excluding the four unclassified probes, which
are prose in `<flagged_assumptions>` rather than truths). Nothing was dropped in either
direction — every one of the 6 probes is accounted for, and every edge-tagged truth states
whether it came from the probe or from the planner.
Also verified and unchanged: the 4 `unclassified` entries sit under `<flagged_assumptions>` and
are never promoted to `must_haves.truths`; the 3 prohibitions in 13-08, 13-12 and 13-14 are
flat scalars under `prohibitions`, never under `truths`, and carry no `check_*` keys.
---
## Open Questions Blocking Full Validation
Carried from `13-RESEARCH.md` § Open Questions — each needs a planner decision, and two change what "validated" even means: