Souspli

Souspli / Docs / Design notes

Design note — 2026-07-19. A decision record, written when this was built and kept as written. It uses the code's names (thing, shell, cage — see the glossary) and may describe things that have since changed. Current documentation: spec.

Format-spec notes from the phase-2 bridge build

The brief says: when the bridge build proves the format spec impractical, note it — we update the spec rather than working around it. Nothing below is a workaround in the code; these are the spec deltas the implementation surfaced.

1. Recommend (not require) that manifest.args be JSON-representable (§4)

args is CBOR any, so a manifest can carry maps with integer keys or byte strings, which structured-clone surfaces to JS as Map/Uint8Array. Most programs will want plain JSON-shaped args, and a program author who receives a Map where they expected an object will lose an afternoon to it. Add a SHOULD to §4: authors are RECOMMENDED to keep args JSON-representable. The bridge passes through whatever structured clone supports either way — this is authoring guidance, not a decoder rule.

2. Add a "shell obligations" section; record that decrypted sealed content is memory-only

The spec currently defines the wire format only, but §7/§8 create an obligation it never states: a shell that admits a sealed bundle holds decrypted private bytes, and if it writes them to its persistent content-addressed store it has silently put someone's private attachment on disk in the clear. The phase-2 shell serves sealed attachments from an ephemeral in-memory store scoped to the cage's lifetime; the persistent CAS is for public things only (and the suite pins this). When the spec grows a "shell obligations" section, record this as a MUST.

3. Attachment-table keys need constraints (or an explicit note that shells may refuse names)

§4 keys attachments by arbitrary tstr. Shells that serve attachments by name over a URL route (thing://<id>/att/<name>) cannot round-trip every possible string: a name containing .. is refused by any sane URL parser (dot-segment normalization), and path separators / control characters are trouble in every transport that touches a path. The phase-2 publish path rejects names that are empty, longer than 255 chars, or contain /, \, .., or control characters. Suggest the spec either constrain table keys the same way (cheap, matches what NFC normalization already implies about being picky at the boundary) or state that shells MAY refuse to admit manifests whose attachment names cannot be served. Silent per-shell divergence here would be an interop trap.

4. The publish draft contract has no MIME for inline blobs

The brief's emit("publish", {type, args, blobs: {name: Uint8Array}}) carries bare bytes, but a manifest Att row requires m (MIME). Phase 2 records application/octet-stream and defers the real value to the review/sign flow. Before the sign/seal phase lands, decide: either the publish contract grows a per-blob MIME (e.g. {bytes, mime} values — a shape change the brief wanted to avoid), or the spec/shell blesses "MIME is chosen at review time" as the rule. Sniffing inside the shell is the one option that should stay off the table (§4's nosniff stance).

5. Confirmation: nothing else fought back

The ThingArgs view, name-keyed getBlob, admission-time-only hashing, and the CAS layout (blobs/<hex-hash>, §8's bundle shape) all mapped onto the spec cleanly. §10.4 (program supersedes) says "decide when the bridge lands" — the bridge landed without needing it; it can stay parked until the naming layer.

6. The draft contract re-ships blob bytes on every emit

emit("draft", {type, args, blobs}) is whole-set replacement, so a program that wants to keep an image must name it in every emit. Phase 2 added {carry: true} for the case where the shell already holds the bytes under that name, which covers re-mounts (the image is in getArgs().attachments, so the program can carry it rather than re-read the file). It does not cover the within-session case: an image the human just picked has no mount-time attachment name, so its bytes cross IPC again on every subsequent keystroke until the draft is re-mounted. Debouncing hides the cost rather than removing it, and a multi-image article makes it concrete — a 3 MB photo re-crossing the boundary per edit.

The fix is a blob handle: the shell returns an opaque token when it accepts inline bytes, and the program names the token thereafter — the same idea as carry, extended to bytes the shell has accepted but not yet mounted. That is a bridge-surface change, so it wants a decision at the same time as anything else touching the 4-method surface, and it must keep the property that a handle is not a capability to read anything the program could not already read (it names bytes the program itself just supplied, nothing more).