Full-TDD per-stream plans for the v0.9.0 multi-agent train: - org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session + org config + grant-filtered manifest read. - org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner. - org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write handlers + UI. Spike-gated; NO-GO ships read-only. - keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint + WASM bindings + CLI init/unlock --key-file. - keyfile-ext-positioning (B3+B4): setup container choice + unlock + the README/DESIGN/CRYPTO/FORMATS positioning pivot. Cross-plan contracts pinned and self-reviewed for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
14 KiB
Org Write Implementation Plan (spike-gated)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. Task 1 is a GO/NO-GO spike — do not start Tasks 2+ until it passes.
Goal: Let org members add, edit, and delete org items from the extension — via ed25519-signed commits the org pre-receive hook accepts.
Architecture: The org hook rejects unsigned commits, and the extension's Contents-API write path produces unsigned commits. So org writes go through the host Git Data API (blob → tree → signed commit → update-ref), signing the commit object with sign_for_git (already in WASM, unused today). gitea.ts/github.ts already use the Git Data API for large attachments, so this extends existing machinery. Whether the host API preserves a caller-supplied SSH signature through to git verify-commit is unproven — Task 1 proves it before anything else is built.
Tech Stack: TypeScript (extension SW + UI), vitest; relicario-server (the hook, for the spike); a live Gitea (and GitHub) repo for the spike.
Global Constraints
- Release target: v0.9.0.
- Task 1 gates the rest. If the spike fails on both hosts, STOP: record the result in
docs/superpowers/specs/2026-06-20-extension-org-gui-design.mdandSTATUS.md, ship org read (Plans 1+2) for v0.9.0, and move org write to a follow-up. Do not build Tasks 2–5 against a push path the server will reject. - Consume Plan 1's context model + Plan 2's read UI. Org writes operate on the current org context.
- Manifest mutation writes BOTH
items/<slug>/<id>.encANDmanifest.enc(the personal "both writes" invariant) — both inside ONE signed commit where possible. - Org master key stays in the Zeroizing WASM handle; signing uses the device key from
chrome.storage.local.device_private_key. - Capitalize "Relicario" in prose.
File Structure
- (spike)
docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md(new) — the experiment + its result. extension/src/service-worker/git-host.ts— addcommitSigned(files, message, sign)to the interface.extension/src/service-worker/gitea.ts/github.ts— implementcommitSignedover the Git Data API with an attached SSH signature.extension/src/service-worker/org-vault.ts—orgAddItem/orgUpdateItem/orgDeleteItem(encrypt with org handle, collection-scoped path, manifest update, signed commit).extension/src/service-worker/router/org-handlers.ts,shared/messages.ts—org_add_item/org_update_item/org_delete_item.extension/src/popup/components/item-list.ts,item-detail.ts,item-form.ts— un-hide write affordances in org context; collection picker on add.- Tests:
extension/src/service-worker/__tests__/org-write.test.ts; component tests for the org write affordances.
Task 1: GO/NO-GO spike — signed commit via the host Git Data API
Files:
- Create:
docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md
This is a spike, not a TDD cycle. The deliverable is a written GO/NO-GO with evidence.
-
Step 1: Set up a throwaway org repo + hook. On a local Gitea, create a repo, install
relicario-server generate-org-hookas the pre-receive hook, and register a test device's ed25519 public key inmembers.json(userelicario org init/add-memberfrom the CLI to bootstrap a valid org repo). -
Step 2: Construct and sign a commit object in a Node/SW-like harness. Build a canonical git commit object (tree + parent + author/committer) for a small
items/<slug>/<id>.encchange, sign the commit payload with the device key viasign_for_git(export it the way the SW would call WASM), and format the SSH signature into the commit (gpgsig-style SSH signature block). -
Step 3: Push it via the Gitea Git Data API.
POST /git/blobs→POST /git/trees→POST /git/commits(with thesignaturefield if Gitea supports it; otherwise the raw signed commit object) →PATCH /git/refs/heads/{branch}. Record the exact API shape that carries the signature. -
Step 4: Verify server-side. Confirm the pushed commit passes
git verify-commit <sha>on the server AND is accepted byrelicario-server verify-org-commit. Repeat Steps 2–4 against GitHub (create-commitsignaturefield). -
Step 5: Record the verdict. Write the spike doc with: GO/NO-GO per host, the exact Git Data API call sequence that worked (or the failure mode), and any constraints (e.g., committer identity must match). Commit it.
git add docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md
git commit -m "spike: org signed-commit push via host Git Data API — <GO|NO-GO> result"
Decision gate: GO on at least one host → continue to Task 2 (ship write for the passing host(s), note any host-specific limitation). NO-GO on both → update the spec + STATUS.md, ship org read only, stop here.
Task 2: commitSigned GitHost method
Files:
- Modify:
extension/src/service-worker/git-host.ts,gitea.ts,github.ts - Test:
extension/src/service-worker/__tests__/git-host-signed.test.ts
Interfaces:
-
Consumes:
wasm.sign_for_git(data: Uint8Array); device key fromchrome.storage.local. -
Produces:
commitSigned(files: Array<{ path: string; content: Uint8Array }>, message: string, sign: (payload: Uint8Array) => string): Promise<void>on theGitHostinterface — builds one commit containing allfilesvia the Git Data API, signs the commit object, pushes per the Task-1 sequence. -
Step 1: Write the failing test (mock fetch to the Git Data API endpoints; assert the call sequence + that the commit body includes the signature)
test('commitSigned creates a blob+tree+signed-commit and updates the ref', async () => {
const calls: string[] = [];
globalThis.fetch = vi.fn(async (url: string) => { calls.push(String(url)); return okJson({ sha: 'x' }); }) as any;
const host = new GiteaHost('https://git.example', 'o/r', 'tok');
await host.commitSigned([{ path: 'items/c/i.enc', content: new Uint8Array([1]) }], 'add', () => 'SSHSIG...');
expect(calls.some(u => u.includes('/git/blobs'))).toBe(true);
expect(calls.some(u => u.includes('/git/commits'))).toBe(true);
expect(calls.some(u => u.includes('/git/refs/'))).toBe(true);
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts
Expected: FAIL — commitSigned not defined.
-
Step 3: Implement — extend the existing Git Data API code (the large-attachment path) into
commitSigned, following the exact sequence the spike proved. AddcommitSignedto theGitHostinterface and both hosts. -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/service-worker/__tests__/git-host-signed.test.ts
Expected: PASS.
- Step 5: Type-check + commit
Run: cd extension && npm run build:all
git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts extension/src/service-worker/__tests__/git-host-signed.test.ts
git commit -m "feat(ext/sw): commitSigned — signed multi-file commit via Git Data API"
Task 3: Org write SW handlers
Files:
- Modify:
extension/src/service-worker/org-vault.ts,router/org-handlers.ts,router/popup-only.ts,shared/messages.ts - Test:
extension/src/service-worker/__tests__/org-write.test.ts
Interfaces:
-
Consumes:
commitSigned(Task 2); the orgOrgHandleState(Plan 1);wasm.item_encrypt,wasm.manifest_encrypt. -
Produces: SW messages
org_add_item { collection, item },org_update_item { id, item },org_delete_item { id }. Each encrypts with the org handle, writes the collection-scopeditems/<slug>/<id>.encAND the updatedmanifest.encin ONE signed commit, and refuses writes to ungranted collections client-side (the hook is the backstop). -
Step 1: Write the failing test
test('org_add_item refuses a collection the member is not granted', async () => {
const state = { ...orgStateWithGrants(['prod-infra']) };
const resp = await handleOrgAddItem({ collection: 'secret-ops', item: fakeLogin }, state);
expect(resp).toEqual({ ok: false, error: 'collection_not_granted' });
});
test('org_add_item writes item + manifest in one signed commit to the granted path', async () => {
const state = { ...orgStateWithGrants(['prod-infra']) };
const commit = vi.spyOn(state.host, 'commitSigned').mockResolvedValue();
await handleOrgAddItem({ collection: 'prod-infra', item: fakeLogin }, state);
const [files] = commit.mock.calls[0];
expect(files.map((f: any) => f.path).sort()).toEqual(['items/prod-infra/' + expect.any(String), 'manifest.enc'].sort());
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts
Expected: FAIL — handlers undefined.
-
Step 3: Implement the three handlers in
org-vault.ts(encrypt, collection-scoped path, manifest update, singlecommitSigned), wire the three messages (union +POPUP_ONLY_TYPES+ dispatch), and the grant check. -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/service-worker/
Expected: PASS (org read + write + personal all green).
- Step 5: Type-check + commit
Run: cd extension && npm run build:all
git add extension/src/service-worker/org-vault.ts extension/src/service-worker/router/org-handlers.ts extension/src/service-worker/router/popup-only.ts extension/src/shared/messages.ts extension/src/service-worker/__tests__/org-write.test.ts
git commit -m "feat(ext/sw): org_add/update/delete_item via signed commits"
Task 4: Org write UI
Files:
- Modify:
extension/src/popup/components/item-list.ts,item-detail.ts,item-form.ts - Test:
extension/src/popup/components/__tests__/item-form.test.ts(org additions)
Interfaces:
-
Consumes:
org_add_item/org_update_item/org_delete_item;PopupState.orgCollections,PopupState.orgOffline. -
Produces: in org context — the "+ new item" button reappears (Plan 2 hid it) with a granted-collection picker; edit + delete in the org item detail; all write affordances disabled when
orgOffline. -
Step 1: Write the failing test
test('org add form requires a granted collection and sends org_add_item', async () => {
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: false,
orgCollections: [{ slug: 'prod-infra', display_name: 'Prod' }] } as any);
const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: {} } as any);
await renderItemForm(document.createElement('div'), { type: 'Login' });
// fill + pick collection 'prod-infra' + save → assert message
// ...
expect(send).toHaveBeenCalledWith(expect.objectContaining({ type: 'org_add_item', collection: 'prod-infra' }));
});
test('write affordances are disabled when org is offline', async () => {
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgOffline: true } as any);
const el = document.createElement('div'); await renderItemDetail(el, fakeItem);
expect(el.querySelector('[data-action="edit"]')?.hasAttribute('disabled')).toBe(true);
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/popup/components/__tests__/item-form.test.ts
Expected: FAIL — no org collection picker / no save routing / not disabled offline.
-
Step 3: Implement — in org context, the save path sends the
org_*messages (route via a small helper paralleling Plan 2'smessageForList), add a collection<select>(fromorgCollections) to the add form, and gate edit/delete/add on!orgOffline. -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/popup/components/
Expected: PASS.
- Step 5: Type-check + commit
Run: cd extension && npm run build:all
git add extension/src/popup/components/item-list.ts extension/src/popup/components/item-detail.ts extension/src/popup/components/item-form.ts extension/src/popup/components/__tests__/item-form.test.ts
git commit -m "feat(ext): org write UI — collection picker, edit/delete, offline-gated"
Task 5: Org write acceptance tests
Files:
-
Create/extend:
extension/src/service-worker/__tests__/org-write.test.ts -
Step 1: Write the acceptance tests — (a) a write produces a
commitSignedcall whose commit the org hook contract would accept (assert a signature is attached); (b) an ungranted-collection write is refused client-side; (c) every org write touches BOTH the item path andmanifest.enc; (d) offline context blocks writes before any network call. -
Step 2: Run them
Run: cd extension && npx vitest run src/service-worker/__tests__/org-write.test.ts
Expected: PASS.
- Step 3: Full suite + commit
Run: cd extension && npx vitest run && npm run build:all
git add extension/src/service-worker/__tests__/org-write.test.ts
git commit -m "test(ext/sw): org write acceptance — signature, grants, dual-write, offline"
Notes
- The spike (Task 1) is the project risk concentrated into one day. Treat a NO-GO as a legitimate, value-preserving outcome: org read (Plans 1+2) + the entire key-file lift (Plans 4+5) still ship in v0.9.0.
- Committer identity: the org hook attributes the audit entry to the verified signing key; make sure the commit's committer/author and the signature key are consistent with the member's
members.jsonrecord (the spike must confirm the hook's matching rule).