Commit graph

245 commits

Author SHA1 Message Date
c489a78f34 feat(server): enable edit-history on the project-level personal workspace
Per the working-folder design: <project>/working/<email>/ is each user's
personal workspace (public by default, owned by the creator who can privatize
via .zddc). The post-reshape defaults had stripped that node to a bare
aggregator, so personal markdown drafts got no history. Add history: true +
an auto_own (un-fenced) per-user-home rule to the project-level working node.
archive/<party>/working/ keeps its own history: true. Scope stays working-only
(staging/reviewing unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:36:56 -05:00
e58e66a49c chore(embedded): cut v0.0.25-beta 2026-05-28 14:20:21 -05:00
d00afa1ddc fix(server): carry history through the paths-tree merge
mergeOverlay (used to thread embedded defaults' paths: tree into chain
levels) didn't copy the new History *bool, so EffectiveHistory never saw
history: true on archive/<party>/working/ — the feature would have silently
never triggered. Add the field to the overlay and a HistoryAt defaults test
that exercises the real cascade (working/ + fenced homes true; sibling slots
false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:48:49 -05:00
6efe71e573 feat(server): edit-history versioning for working-folder markdown
A history: true .zddc subtree (enabled by default on archive/<party>/working/)
routes markdown PUTs through WriteTextWithHistory: each save snapshots the
content into a hidden, immutable .history/<stem>/ store (content-addressed
blobs + an append-only log.jsonl carrying server-stamped {ts, email, sha,
prev}) before writing the live file. The live file at its natural path stays
the source of truth; no symlinks, no audit in the body/filename.

Reads: GET <file>?history=1 lists versions (newest-first, current flagged);
GET <file>?history=<sha> returns that version's bytes (hex-id guard against
traversal). Listings carry a per-file History flag so the browse client knows
where to offer the affordance.

History is subtree-inheriting and ignores inherit:false ACL fences (versioning
is a write behavior, not a permission), so fenced per-user homes under working/
are covered too. No-op saves dedup; pre-existing files lazy-seed their origin
version. Records (.yaml) keep their existing in-body-audit history path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:51 -05:00
de046360e6 release: v0.0.24 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 21s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 11:11:36 -05:00
d4f35d9927 release: v0.0.23 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 20s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 08:59:18 -05:00
9cec423361 release: v0.0.22 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 07:28:42 -05:00
b1ef81077e chore(embedded): cut v0.0.22-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 9s
2026-05-21 17:10:23 -05:00
d0d8423ac6 test(handler): un-skip the profile existence-hiding invariant
TestInvariant_ProfileAdminEndpointsHideFromNonAdmins was skipped pending the
ServeProfile dispatcher refactor — which has since landed (ServeProfile in
profilehandler.go is the entry point, with an adminOnly wrapper that denies
with 404). Implement the test against it: non-admin, anonymous, and
un-elevated-admin callers must get 404 (never 403/200) on every admin-gated
sub-resource (/whoami, /config, /logs, /effective-policy, /reindex), so the
namespace can't be enumerated; an elevated admin gets through (/whoami,
/config positive control). Locks in the existence-hiding security property
that was previously unverified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:41:29 -05:00
4f021d8abc fix(archive): log swallowed walkdir errors during transmittal indexing
indexTransmittalFolder silently dropped per-entry walk errors (`_ = err`),
so a permission or filesystem error on one file vanished without a trace —
the operator saw "missing from the index" with no clue why. Log it (the
slog.Warn the comment had already drafted) and keep indexing the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:41:29 -05:00
1402864c4c fix(cache): track background revalidation goroutines; drain on shutdown + in tests
Root cause of the flaky cache tests (TestServeHTTP_DirectoryListingsCachedAsSidecar
and the other hit-path tests, ~1-in-many under parallel load): on a cache
hit, ServeHTTP launches `go c.revalidate(...)` / `go c.revalidateListing(...)`,
which write into the cache root (MkdirAll + CreateTemp + Rename). Those
goroutines outlive the request — and in tests, the test — so they race
t.TempDir's RemoveAll cleanup, recreating the dir or dropping a temp file
mid-removal. testing then reports "TempDir RemoveAll cleanup: ... directory
not empty" and marks the test failed (with a 0.00s body, no assertion line).
It only surfaced under the full parallel suite / -count because the timing
has to collide.

Fix: track these background goroutines in a sync.WaitGroup via a goBackground
helper, and expose Wait(). newTestCache registers t.Cleanup(c.Wait) — cleanups
fire LIFO and t.TempDir registered its RemoveAll first, so the drain runs
before it (upstream Close was registered earliest, so it runs last and stays
up while goroutines finish). runClient also calls cacheLayer.Wait() after
srv.Shutdown so in-flight sidecar writes complete on graceful shutdown rather
than being abandoned.

Verified: cache package at -count=200 reliably failed before, passes clean
after (0 failures, 0 cleanup errors); full `go test ./...` + vet green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:21:37 -05:00
7dfedc2342 feat(form): ui:mirrorFrom — reflect a sibling field into a read-only field
The project-rollup forms derive originator from the selected Package
(party folder) server-side, so the field is read-only and was blank
until submit. Add a declarative `ui:mirrorFrom: <sibling>` hint: the
object renderer wires the named sibling's input to the field so the
read-only originator updates live as the user picks a party — the
composing tracking number is visible while filling the form. Display
only; the server stays authoritative via the cascade's folder_fields.

Set `ui:mirrorFrom: party` on originator in the embedded
default-project-{mdl,rsk}.form.yaml. Generic hint, not hardcoded field
names, so operators can reuse it.

Test: form-safety.spec.js — filling the source field updates the
read-only target; the target is not editable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:43 -05:00
875827d484 fix(records): validate folder_fields at load time + cache field-code patterns
- Add RecordRule.UnmarshalYAML so a misconfigured folder_fields fails
  when the .zddc is parsed, not as a 500 on the first record write. A
  negative parent-distance is now rejected with a message naming the
  field. Mirrors FieldCode.UnmarshalYAML's raw-alias pattern.
- Memoize anchored field-code pattern regexes in a package-level
  sync.Map (compileFieldPattern), used by both the unmarshal-time
  validation and FieldCode.Validate — replacing the per-call
  regexp.Compile that the old comment flagged as cache-if-it-shows-up.

Tests: negative distance rejected (standalone + nested in a records:
map), valid distance round-trips, pattern field code matches anchored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:28:35 -05:00
662bfbdbf9 refactor(records): converge all record-write paths on WriteWithHistory
The in-dir form create/update (serveFormCreate/serveFormUpdate) wrote
records with plain WriteAtomic + date+email naming — no audit stamping,
no filename composition, no field_codes/folder_fields. So "+ Add row"
from a per-party mdl/rsk table produced un-stamped, mis-named rows that
the tables tool's own PUT-update path (which composes) would then 422
on. Only PUT and the project rollup honored the record machinery.

Now every record-write entry point converges on WriteWithHistory:

- Extract the shared field_defaults + folder_fields + row-assign +
  compose step into recordCreatePrep (history.go); the rollup uses it
  too, replacing its inline copy.
- serveFormCreate: when a records: rule with a filename_format applies
  in the target dir, compose the name + route through WriteWithHistory;
  otherwise keep the generic date+email submission write.
- serveFormUpdate: route through WriteWithHistory unconditionally — it
  stamps/historizes records and plain-writes non-records. Editing a
  tracking-number component in place now 422s (identity is the
  filename; renames are delete+create).
- Drop originator from required: in the per-party mdl/rsk forms and mark
  it readOnly, matching the rollup forms — it's server-derived from the
  party folder, so a create needn't send it.

Docs (AGENTS.md, ARCHITECTURE.md) updated for the converged wire
surface. Tests: in-dir record create composes + stamps audit +
folder-binds originator; in-dir update bumps revision and rejects an
in-place component edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:48:52 -05:00
e3db2f8473 feat(records): simplest default tracking number + folder-bound originator
Two coupled cleanups so the baked-in defaults reflect the actual
convention instead of leaking one project's choices into every
deployment:

- Drop the project-wide phase/area components from the default
  filename_format, form schemas, and table columns. They must be
  all-on or all-off across a project to keep filenames lexically
  consistent, so the simplest default omits them; operators re-enable
  via the commented-out templates + a .zddc filename_format override.
  Teaching comments (incl. a field_codes: example) now ride along in
  defaults.zddc.yaml, which `show-defaults` dumps verbatim.
- Separate suffix from sequence with a template hyphen
  ({sequence}-{suffix?}); stored suffix is now just the part marker
  (A, 01) with no leading dash.
- New records: key `folder_fields: {field: parent-distance}` binds a
  body field to an ancestor folder name. The default mdl/rsk records
  bind originator to the party folder (distance 1) — the folder is the
  sole source of truth. The server overwrites the body value before
  validation + composition (WriteWithHistory and the rollup create
  path), and the form renderer marks the field read-only and pre-fills
  it. Rollup forms drop originator from required (server derives it
  from the selected party).

Tests: folder-binding overwrite + wrong-originator-filename 422, and a
form-render readOnly/prefill assertion; existing record tests realigned
so the party folder name equals the originator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:31:49 -05:00
cc7f34e922 fix(listing): synthetic table.yaml/form.yaml verbs reflect actual authority
The synthetic spec entries injected into rollup virtual surfaces
(/<project>/{ssr,mdl,rsk}/) had Verbs hardcoded to "r" — so even
an elevated root admin saw the spec files as read-only in the
YAML editor's verbs check (cap.has(node, 'a') returned false →
saveBtn disabled + the red read-only banner).

The hardcode was a Part 2 oversight; every other synthetic listing
entry already computes verbs via EffectiveVerbsFromChainP against
the entry's path. Now table.yaml and form.yaml do the same — elevated
admins get "rwcda" and can PUT a custom spec to override the embedded
default at the rollup view; everyone else still gets "r" via the
project-level project_team:r grant cascading through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:23:12 -05:00
0a6f9fe60a chore(embedded): cut v0.0.22-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
2026-05-21 11:30:06 -05:00
b4d59b11ee release: v0.0.21 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-21 11:27:51 -05:00
90a31020db fix: clear the 14 stale Playwright baseline failures
Four root causes, each affecting one or more pre-existing
failures. All resolved without weakening any assertion.

1. build-label.spec.js (×4 — archive/transmittal/classifier/browse)
   The regex accepted v<X.Y.Z>-alpha|beta channel labels but not the
   -dev label modern dev builds emit. CLAUDE.md describes
   v<X.Y.Z>-dev as the canonical dev-build form. Added |dev to the
   channel alternation; tests now pass on dev builds and remain
   tight on stable cuts.

2. landing.spec.js (×8)
   SAMPLE_PROJECTS fixture pre-dated the post-reshape listing JSON
   contract. The landing's loader now filters projects on
   `is_dir: true`; the fixture didn't set it, so every entry was
   filtered out and every "renders a project table" test failed at
   the `.project-table` wait. Added `is_dir: true` (and trailing
   slash on names, matching the live server's shape) to the three
   fixture entries.

3. browse.spec.js (×1 — Download (zip))
   The #downloadZipBtn toolbar button was retired in the SPA
   overhaul (94b2e29) — Download ZIP moved to the right-click
   context menu. Test still poked the dead toolbar button. The
   picked-root folder no longer renders as a row (only its
   contents do), so the test now scopes the assertion to
   downloading a sub-folder (sub/) via right-click → Download ZIP;
   verifies the zip's entries, magic bytes, and filename.

4. tables.spec.js (×1 — Phase 3 row-blur fires PUT)
   Real bug, not a test issue. The editor's commit path tears down
   its input element (clearing focus to body) before refocusing
   the owning cell. main.js's focusout-on-#table-root handler ran
   synchronously, saw `relatedTarget=null`, treated it as "user
   left the grid", and fired flushAll() — racing the
   selection-change save that fires from the subsequent
   setSelected(r+1, c) inside the Enter handler. Net effect: two
   identical PUTs per row-blur. Deferred the focusout check to
   next tick via setTimeout(0); the cell.focus() inside the
   editor's tearDown has time to settle, and the deferred check
   sees document.activeElement still inside #table-root → skips
   the redundant flush.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:24:30 -05:00
736f422f82 fix(roles): restate document_controller at project_team slot grants
DCs are typically internal employees and ARE in project_team (when
project_team is the realistic *@example.com wildcard). The cascade's
"deepest level that has any matching principal wins" semantic means
a project_team:cr grant at the slot level would shadow the DC's
party-level rwcda — leaving DCs limited to project_team's grant.

Fix: at every slot with a project_team-specific grant, restate
document_controller's role grant. The within-level union of all
matched principals then gives the DC rwcda ∪ cr = rwcda. No cascade
semantics change; just verbose defaults.

  working/   project_team: cr, document_controller: rwcda  (new DC line)
  staging/   project_team: cr, document_controller: rwcda  (upgraded from rwcd —
                                                            adds `a` for
                                                            Plan Review's
                                                            staging/<tracking>/.zddc)
  reviewing/ project_team: cr, document_controller: rwcda  (new DC line)

Test fixture flipped from disjoint-role members to the realistic
project_team: ["*@example.com"]; verifies DC's rwcda survives the
wildcard via within-level union at each slot.

Docs updated:
  - AGENTS.md "Standard roles": describes the role-restate pattern
    + flags the internal-observer-via-wildcard caveat (operators
    needing internal observers should avoid the *@ wildcard for
    project_team).
  - ARCHITECTURE.md "Standard roles": same model description; drops
    the now-incorrect "subtree-admin of every archive/<party>/"
    line, replaces with the auto_own_roles role grant.
  - planreview_test.go fixture comment: reflects that the test
    uses root-admin to bypass ACLs, with non-root-admin DC path
    covered by standardroles tests' auto-own .zddc simulation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:03:42 -05:00
ba98b87b2a feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin
Two related schema/defaults changes that together replace the
admins:[document_controller] subtree-admin status with a cleaner
role-grant-via-auto-own model, and lock down the one-way handoff
through the in-flight lifecycle slots.

## New: auto_own_roles

ZddcFile.AutoOwnRoles []string is a new field on the parent's .zddc
declaring "when this directory's auto_own fires, also grant these
roles rwcda alongside the creator email". The writer
(WriteAutoOwnZddc + WriteAutoOwnZddcFenced) now takes a roles slice
and writes both the creator email AND each named role as rwcda in
the new .zddc. mergeOverlay treats AutoOwnRoles like other path-tree
contributions (leaf-wins).

The defaults' archive/<party>/ entry now sets
`auto_own_roles: [document_controller]` and drops the
`admins: [document_controller]` line:

  - When any DC mkdir's archive/<party>/, the auto-own .zddc grants
    both their email and the role rwcda. Peer DCs share full
    authority at every party without any DC needing subtree-admin
    status.
  - DCs are no longer subtree-admins anywhere. They can't bypass
    WORM (only worm-create via the worm: list) and can't reach
    inside fenced working homes. Admin elevation is reserved for
    the root admins: list.
  - Plan Review's ActionAdmin pre-flight passes for any DC via the
    role grant cascading into reviewing/ and staging/.

## In-flight ratchet (working → staging → issued)

Per-role grants at the lifecycle slots formalise a one-way handoff:

  working/   project_team: cr (create their own folders;
                              auto_own_fenced gives rwcda inside)
  staging/   project_team: cr (drop files, no modify after — the
                              "commit" step; DC takes over)
             document_controller: rwcd (transfer-to-issued needs `d`)
  reviewing/ project_team: cr (create iteration folders; auto_own
                              unfenced grants rwcda inside)
  received/  worm cr (file write-once)
  issued/    worm cr

Each handoff drops the previous role's modify rights for the slot
they pushed from. Comments in defaults.zddc.yaml document the
pattern + the "project_team drops files at staging root, never
mkdirs" convention.

## Tests

TestStandardRoles_DocControllerScopedCreate rewritten — flips
from IsSubtreeAdmin assertions to verifying:
  - rwcda at <party>/ via the auto-own .zddc (creator + role)
  - rwcda cascading to working/reviewing/ (no slot override)
  - rwcd at incoming/staging/ via explicit grants
  - cr at received/issued via WORM mask
  - IsSubtreeAdmin = false everywhere
  - DC blocked from alice's fenced working/<email>/ home

New TestStandardRoles_DocControllerMultiDC — a second DC in the
role gets the same rwcda at any party a peer created, via the role
grant in auto_own_roles.

New TestStandardRoles_ProjectTeamInFlightRatchet locks the ratchet:
project_team gets cr at working/staging/reviewing, r at incoming/
received/issued.

New TestStandardRoles_DocControllerStagingDelete confirms DC has
`d` at staging/ for the transfer-to-issued workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:51:07 -05:00
b5a725e745 feat(zddcfile): ?effective=1 composed-cascade inspection query
Add GET /<path>/.zddc?effective=1 returning JSON with the composed
ZddcFile across the full cascade plus a per-level source list. The
.zddc file itself still serves only what's defined at that level
(YAML, the source of truth); the new query is inspection-only
(JSON, never written back). The virtual .zddc body's header
comment already pointed at this URL — now it's live.

Wire shape:
  { url_path: "/Project-1/archive/Acme/working/",
    merged:  { …ZddcFile JSON, composed view… },
    sources: [ { level: -1, url: "<embedded>",
                 contributed: ["roles", "available_tools", "paths"] },
               { level: 0,  url: "/.zddc",
                 contributed: ["acl", "admins"] },
               { level: 4,  url: "/Project-1/archive/Acme/working/.zddc",
                 contributed: ["default_tool", "auto_own", …] } ] }

New zddc.EffectiveZddc(chain) walks chain.Embedded then
chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the
cross-level Roles union (via the existing lookupRoleMembers,
matching the runtime ACL evaluator's semantics). Returns
([]SourceEntry) listing each contributing level with its non-zero
top-level fields. The handler maps SourceEntry.Level to a directory
URL: -1 → "<embedded>"; 0..n → "/<seg/seg/.../>.zddc".

ACL gate is the same as the YAML view (read on the directory).
X-ZDDC-Source: virtual:effective so clients can distinguish.

Four tests cover the contract:
  - BasicCompose: alice's root grant + project_team baseline from
    embedded + the project's title all surface in merged; sources
    include -1 (embedded), 0 (root), 1 (project).
  - InheritFence: top-level inherit:false on /Closed/.zddc drops
    every ancestor including the embedded baseline from sources.
  - RoleMemberUnion: document_controller declared at root and
    project unions members in merged.roles (matches the runtime
    cross-level union the ACL evaluator performs).
  - existing virtual-body tests still pass — they hit the YAML path,
    not the JSON branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:39:29 -05:00
a0a3f8579b feat(zddcfile): virtual .zddc body = leaf cascade level as YAML
When no .zddc is on disk at the requested directory, ServeZddcFile
now renders the cascade's leaf-level ZddcFile as YAML — what
defaults.zddc.yaml's paths: tree declares for THIS exact path,
threaded through by the walker. The previous body was a comment-
only summary plus a `{}` placeholder, which forced operators to
write any override from scratch.

The .zddc file is still the single source of truth — no synthesis,
no merge: the virtual body IS the embedded subtree, marshalled in
the same shape the operator would write themselves. PUT-saving the
bytes back through the file API materialises an on-disk override
carrying exactly what the user saved. For the COMPOSED view across
the full chain, slice 2 will add ?effective=1 (returns JSON, not a
.zddc); the header comment in the virtual body points at it.

Three new test cases lock the contract:
  - VirtualDefault: at /Project/.zddc with no on-disk file, the
    embedded paths.* contribution surfaces (project_team: r,
    observer: r, archive subtree, …).
  - VirtualEmpty: at a path the embedded defaults don't declare
    (e.g. /Project/random-subfolder/.zddc), the body collapses to
    the header + an empty-document {} placeholder + an explanation
    that rules come from ancestors only.
  - VirtualPerPartyWorking: at /Project/archive/Acme/working/.zddc,
    the body carries default_tool/auto_own/drop_target and the
    classifier in available_tools — the per-party in-flight slot's
    full declaration.

Drive-by: add `omitempty` to ZddcFile.ACL, .Admins, .Title yaml
tags. Without it, the marshaled virtual body carried `acl: {}`,
`admins: []`, and `title: ""` at every nested level, drowning the
real content in noise. ParseFile is unaffected (input parsing
ignores omitempty); WriteFile's round-trip sanity check still
passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:32:15 -05:00
43c2879e9c release: v0.0.20 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-21 09:14:36 -05:00
b4a33aa9b3 feat(http): include missing_verb in ACL-deny 403 bodies
ACL-deny sites now write a JSON body naming the missing verb so the
client-side toast can render "you need <verb> here" and offer
elevation (the path-scoped /.profile/access?path= reports whether
elevation would unlock the verb).

Body shape:
  {"error": "Forbidden", "missing_verb": "w"}

New helper writeForbidden(w, action) in errors.go, applied at the
four primary ACL-deny gates:
  - directory.go (list, action=read)
  - fileapi.go (file CRUD; action varies per request)
  - tablehandler.go (table read)
  - archivehandler.go (existence-leak guard, treated as read)

Other 403 sites (no authenticated principal, planreview detail
errors) keep their plain-text bodies — "missing_verb" doesn't apply
there. Existing clients that read the body as text see the JSON
string instead of "Forbidden\n"; no client in this repo parses the
body for content, so it's a non-breaking change in practice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:49 -05:00
477c8826a7 feat(profile): path-scoped fields on /.profile/access?path=<url>
Existing /.profile/access stays unchanged when called without ?path=;
the path-scoped fields are populated only when the caller passes a
URL path, so each tool can fetch its root capabilities in one round
trip and gate top-of-page affordances (transmittal Publish, tables
+Add row, browse +New folder) accordingly.

Three new fields (all omitempty so the global shape doesn't change):
  - path_verbs: rwcda subset granted at the requested path under the
    caller's CURRENT elevation state.
  - path_is_admin: subtree-admin authority at the requested path,
    again under current elevation. Distinct from "verbs include 'a'":
    admin authority is WORM-bypass capability, not just .zddc edits.
  - path_can_elevate_grant: verb set the caller would hold AT THIS
    PATH if they elevated — empty when elevation wouldn't change
    anything (already elevated, or no admin grant on chain). Drives
    toast offers like "Elevate to delete this file".

Path resolution mirrors serveProfileEffectivePolicy: must start with
"/", must not escape ZDDC_ROOT. Validation failures leave the fields
empty rather than 400ing — the global view is still useful, and the
client can detect absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:38 -05:00
53a10ab119 feat(listing): per-entry verbs string for client-side capability gating
Add a `verbs` field (canonical "rwcda" subset) to every directory
listing entry, computed via a new
`policy.EffectiveVerbsFromChainP(ctx, d, chain, p, path)` helper that
routes each of the five actions through the decider and unions the
allowed bits — so an external OPA's overrides surface in the wire
field, and active-admin elevation produces the full grant.

Semantics:
  - file entry: verbs from the parent dir's chain (files inherit;
    they have no .zddc of their own). Same chain Writable uses.
  - directory entry: verbs from the subdir's OWN chain, so a fenced
    or extended .zddc inside it shows through.
  - virtual entries (auto-own homes, canonical-folder placeholders,
    workflow received/ window, table.yaml/form.yaml spec rows):
    verbs computed against the would-be path's chain so client
    affordances render correctly before any write materialises a
    real folder.

Writable stays in lockstep with verbs for the transition window so
existing clients (markdown/yaml editor save buttons) keep working
unchanged. Clients should migrate to checking 'w' in verbs and let
Writable wither.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:25 -05:00
fb50bb5ef6 feat(roles): add observer standard role
A third standard role for auditors, regulators, and external
read-only viewers. Like project_team it gets project-wide `r`, but
unlike project_team the role itself carries no `c` anywhere — so an
observer can't bring a working/<email>/ home into existence under
auto-own, even though the auto-own mechanism is path-keyed rather
than role-keyed.

Approver-by-design: the role audit explicitly rejects a separate
`approver` role. Plan-Review approval stays with document_controller;
two-person sign-off, when needed, is expressed via per-folder `.zddc`
overrides rather than baked-in roles. Comments in defaults.zddc.yaml
and ARCHITECTURE.md call this out so future role audits don't
reopen the question.

TestStandardRoles_ObserverReadOnlyEverywhere locks the invariants:
project-wide r, no c at archive/incoming/working/staging/reviewing,
WORM zones read-only (no worm-create), and not subtree-admin
anywhere even when notionally elevated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:59:44 -05:00
59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00
bd8301d0f2 release: v0.0.19 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 17s
Build + deploy releases / notify-chart-prod (push) Successful in 8s
2026-05-20 10:45:29 -05:00
fac6e7f0d6 release: v0.0.18 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Failing after 12s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-20 09:37:56 -05:00
bdd14609d1 build: simplify to stable + exact-version (drop alpha/beta as public concepts)
Releases publish only two things per tool now: a current-stable
canonical symlink and an immutable per-version file. No more channel
mirrors (_stable/_beta/_alpha) and no more partial-version pins
(_v<X.Y>, _v<X>) — those were debt from a release model that never
matched the project's actual usage.

The `./build beta` verb stays, but narrowed: it's an internal SHA
snapshot for the BMC dev chart pipeline (chart's appVersion pins to
"<X.Y.Z>-beta-<sha>" and the chart Dockerfile fetches that SHA from
git). No public artifact on /srv/zddc/releases/. The embedded/* +
chore commit produced by `./build beta` is the actual snapshot.

`./build alpha` is removed entirely.

build/build-lib.sh:
- Drop alpha verb; narrow beta verb to embedded regen + chore commit
- promote_release: stable cut writes <tool>_v<X.Y.Z>.html + <tool>.html
  symlink + <tool>.html.sig companion symlink; beta is a no-op
- promote_zddc_server: same shape — per-version binary +
  per-platform canonical symlink (zddc-server_<plat>) + .sig symlink
- write_zddc_server_stub: singular; emits per-version stubs +
  one canonical zddc-server.html for current stable
- Delete _promote_channel, verify_channel_links, _channel_is_active
- Seed-from-live now copies only per-version files + .sig + pubkey.pem
  (the canonical symlinks get rewritten by this cut; old layout files
  get cleaned by deploy's --delete-after)
- build_releases_index: dropdown simplified to "latest stable +
  pinned versions"; channels-explainer section removed; tool cards +
  CTA URLs point at canonical <tool>.html / zddc-server_<plat>;
  composer emits "stable" sentinel for `apps:` entries
- Fix the acl:{allow:[...]} footgun in the apps_pubkey example

apps.go:
- isValidChannelOrVersion: accept only "stable" + exact X.Y.Z
  (drop alpha/beta and partial pins v0.0/v0)
- normalizeChannel: same
- Resolve URL composition: stable → canonical <prefix>/<app>.html
  (no _stable_ suffix), exact-version → <prefix>/<app>_v<X.Y.Z>.html
- Tests rewritten to match (beta/alpha replaced with v0.0.4 / stable;
  a new TestParseSpec_RejectsLegacyChannelsAndPartialPins locks in
  that the removed forms now error)

browse/build.sh: gate promote_release on $is_release like every other
tool's build.sh (longstanding inconsistency that errored under the new
promote_release case-statement).

freshen-channel: deleted (no channels to freshen).

Net: -254 lines, all green on full `go test ./...`. Dev build verified
via `./build` (no-arg) — new label format "v<next>-dev · <ts> · <sha>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:17:46 -05:00
cd05cd6366 docs+server: document the .zddc bootstrap config + warn at startup
A fresh ZDDC deployment grants no access to anyone until an operator
populates the root .zddc (admins) and per-project .zddc files (role
members). Until now this was only documented in comments inside the
embedded defaults.zddc.yaml, surfaced via `zddc-server show-defaults`
— operators wiring up a fresh master had no obvious doc to follow and
no startup signal when the bootstrap was missing or empty.

- README.md: new "## Deploy: bootstrap config" section between Tools
  and File-naming convention. Two canonical examples (root admin-only,
  per-project role members), schema essentials (verb bits, principal
  forms, admins-only-at-root), and the acl: { allow: [...] } footgun
  that silently drops grants.
- AGENTS.md: new "### Bootstrap config (REQUIRED — unlocks the server)"
  subsection at the top of ## zddc-server. Same content as README but
  with file:line citations into zddc/internal/zddc/file.go for the
  schema source of truth.
- zddc-server: new warnIfNoBootstrap fires a slog.Warn at startup when
  the root .zddc grants nobody anything (no admins, no acl.permissions,
  no role members). Master mode only; skipped under --no-auth.
- config validator's existing no-root-.zddc fail-fast error message now
  also points at the new README + AGENTS sections so all three signals
  (fail-fast, runtime warning, docs) converge.

Smoke-tested all paths: empty root + default (fail-fast), empty root +
--insecure (file-missing warn), admins-only / perms-only / role-members
-only (silent), title-only and acl.allow footgun (both warn), --no-auth
(suppressed). All existing go tests pass.

Follow-up (manual, separate repo): add an analogous section to
~/src/zddc-website/reference.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:40:47 -05:00
69878532b0 release: v0.0.17 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Failing after 11s
Build + deploy releases / notify-chart-prod (push) Has been skipped
2026-05-19 10:46:42 -05:00
3b2280de7f test(handler): coverage for record audit + history flows
Adds history_test.go with eight cases exercising the record-write
orchestration path:
- CreateStampsAuditFields: PUT to a fresh mdl path → audit fields
  injected; response echoes the stamped YAML; no history dir yet.
- UpdateIncrementsRevisionAndArchivesPrior: second PUT archives
  the prior bytes under .history/<base>/<ts>-<sha8>.yaml, bumps
  revision, preserves created_*, chains previous_sha.
- ConflictPreservesHistory: 412 from stale If-Match leaves the live
  file untouched and writes NO history entry (the failed write must
  be a true no-op).
- ClientAuditFieldsStripped: client-supplied created_by / revision
  are silently overwritten by server values — anti-forgery test.
- FilenameMismatch: URL says ...-0002 but body composes to ...-0001
  → 422.
- LockedFieldRejected: posting type=SPC to an rsk row → 422 with
  /type error (rsk/ locks type=RSK via cascade).
- SSRHistoryAtPartyLevel: writes to archive/<party>/ssr.yaml put
  history at archive/<party>/.history/ssr/, NOT at
  archive/.history/<party>/.
- RollupCreate_AssignsRowAndComposesFilename: three POSTs to
  /project/rsk/form.html in two table-scope groups demonstrate the
  server picks up filename_format + row_field+row_scope_fields from
  the cascade, auto-assigns sequence row numbers per group, and
  composes the canonical filename.

Bug fix surfaced by the first test: composeFilename was eliding TWO
separators around an optional placeholder when one was correct.
"ACM-{phase?}-PRJ" with phase="" was producing "ACMPRJ" instead of
"ACM-PRJ". Now drops only the trailing separator from output and
lets the next iteration emit the connector.

Default-project-{mdl,rsk}.form.yaml updated: project-rollup MDL +
RSK schemas gained the six readOnly audit fields and the project-
rsk schema picked up the full table-tracking component shape (+
row) plus an enum-locked type=RSK. The required: list no longer
includes type for rsk schemas — the cascade's field_defaults
injects it after schema validation, and requiring it would 422
well-behaved clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:08:52 -05:00
d947f616d1 feat(forms): augment served schema with cascade field_codes + locks
Two extension fields added to jsonschema.Schema so server-injected
constraints survive the YAML→Schema→JSON round-trip:
- Pattern: regex hint for the form renderer (server-side validation
  for field_codes already runs via WriteWithHistory).
- ReadOnly: surfaces locked / audit fields as disabled in the UI.
- Labels: x-labels extension carrying human-readable display strings
  paired with enum keys (e.g. ACM → "Acme Inc"), so dropdowns can show
  "ACM — Acme Inc" rather than bare codes.

serveFormRender now calls augmentSchemaFromCascade after loading the
spec: per-field, it injects enum (from field_codes:codes), pattern
(from field_codes:pattern), readOnly (from records:locked), and
default (from records:field_defaults). The augmentation is
per-request and never touches the on-disk *.form.yaml — operators
who declare their own enum/pattern in the spec take precedence
(injection is "if absent").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:58:21 -05:00
d35809cfd8 feat(forms): cascade-driven filename composition + audit on row create
Schemas:
- default-mdl.form.yaml: declare the six readOnly audit fields
  (created_at/by, updated_at/by, revision, previous_sha) so the form
  UI renders them disabled. additionalProperties: false is preserved;
  WriteWithHistory strips any client-supplied values before validation.
- default-rsk.form.yaml: overhaul to reflect the new shape. Each row
  now carries the table-tracking components (originator/phase?/project/
  area?/discipline/type/sequence/suffix?) plus a server-assigned `row`
  field; type is enum-locked to RSK to mirror the cascade's locked: rule.
  Drops the old `id` field (D-001/R-001-style identifiers are now
  composed from the components and stored in the filename).
- default-ssr.form.yaml: append the six audit fields.

Handlers:
- serveFormCreateSSR routes the write through WriteWithHistory so
  audit fields are stamped on first create (revision=1, created_*=
  updated_*=request principal/now). ssr.yaml's identity stays the
  party folder name; no filename composition runs.
- serveFormCreateRollup now resolves the cascade at the row's parent
  folder and uses the matched records: entry's filename_format to
  compose the row filename from body fields. For RSK rows the rule
  carries row_field+row_scope_fields, so the server auto-assigns the
  next sequence (001, 002, ...) within the table-tracking group and
  injects it into the body before composition. Defaults from
  field_defaults: are injected where the client omitted them
  (type=RSK locks in via the locked: list). Falls back to the
  historical date+email naming only when no records: rule is in
  scope (covers deployments that override defaults.zddc.yaml without
  declaring their own records: entries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:55:07 -05:00
882d5e4c86 feat(zddc-server): server-stamped audit + history for record YAMLs
Adds cascade-driven schema + immutable audit history for the three table-style
record stores (mdl, rsk, ssr). Two new .zddc top-level keys carry the rules:

- field_codes: discriminated-union vocabulary (kind: enum|pattern|free) for
  the components used to compose tracking-number filenames and constrain
  record bodies. Map-merge across the cascade, mirror of apps: semantics.
- records: per-pattern rules (filename_format, field_defaults, locked,
  row_field, row_scope_fields). Filename-pattern scoping lets the SSR rule
  live at the party-folder level without bleeding onto mdl/rsk siblings.

PUTs to record YAML files route through a new WriteWithHistory orchestrator
(internal/handler/history.go) which:
- strips six client-supplied audit fields (created_at/by, updated_at/by,
  revision, previous_sha) so the client can't forge them
- validates body values against the cascade-resolved field_codes
- enforces filename_format composition (URL basename must match body fields)
- checks locked: defaults (422 mismatch)
- archives prior bytes to <dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>
- stamps server-managed audit fields and writes the live file

History-before-live ordering preserves the prior version even on mid-write
crash. previous_sha forms a hash chain across revisions for tamper evidence.

The embedded defaults.zddc.yaml now declares records: entries for mdl, rsk,
and ssr.yaml. RSK rows carry the table-tracking components + row sequence
(filename = <table-tracking>-<row>); MDL rows compose to their own
tracking number; SSR records' identity is the party folder name.

GET <record>.yaml?history=1 returns a JSON list of prior revisions, ACL
gated identically to the live record. dot-segment rejection in
resolveTargetPath protects .history/ from direct client writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:48:58 -05:00
f9ba493145 feat(tables): row context-menu opens the form, not raw YAML
Replace "Edit YAML" with "Edit row" — navigates to row.url, which
is already the schema-driven form-mode editor URL. The form handler
unwraps virtual-view URLs server-side so SSR and rollup rows route
to their per-party canonical paths automatically; no client-side
URL rewriting needed.

This fills the gap where row-click only opens the form for
complex-type cells (objects, arrays) — for plain scalars it enters
inline edit mode. Right-click → Edit row is now the discoverable
way to reach the full form for any row.

Raw YAML editing remains available via the browse tool directly
(navigate to the file's parent folder and click it in the tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:43:45 -05:00
1721b4b1db feat(tables): explicit Save button + clearer dirty-row marker
Three triggers for flushing pending edits:
  - Save button in the toolbar — shown only when ≥1 row is dirty,
    label reads "Save (N unsaved)". Disappears after a clean settle.
  - Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats
    the browser's "Save Page As" default.
  - focusout of #table-root with a relatedTarget outside the grid —
    catches "edit cell, click a header link, expect it to save".

The row-blur trigger stays — moving between rows still flushes. The
new triggers fill the gap when the user edits one row and then leaves
the grid entirely without first navigating to another row.

Dirty marker gets a 4px (was 3px) left swatch AND a faint blue
background tint on the row, so "unsaved" reads as a row state rather
than a small marker on the edge.

editor.setDraft / clearDraftField notify save.onDraftsChanged,
which refreshes the Save button + reapplies the dirty class.
saveRow on 200/201/202 also refreshes the button so it disappears
the moment its row settles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:38:35 -05:00
1604b62477 feat(tables): Edit YAML row-context menu item
Opens the row's backing .yaml in the browse tool's YAML editor
(preview-yaml.js — CodeMirror with syntax highlight, lint, Ctrl+S
save). Disabled on multi-row range and unsaved draft rows.

Three URL shapes resolve correctly:
  per-party row → <dir>/?file=<file>.yaml
  SSR virtual   → /<project>/archive/<party>/?file=ssr.yaml
  rollup virtual → /<project>/archive/<party>/<slot>/?file=<file>.yaml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:17 -05:00
f3d334a221 feat(tables): rollup Add Row routes via the party column
The project-level MDL/RSK rollup specs lose `addable: false` and gain
a sibling form schema (default-project-{mdl,rsk}.form.yaml) that
makes `party` a required field. + Add row on the rollup view is now
live: the user types the party name in the Package column, the
server reads `party` from the body, validates that
<project>/archive/<party>/ exists on disk, strips the field, and
writes the row into archive/<party>/<slot>/<date>-<email>.yaml. The
response Location is the synthetic <project>/<slot>/<party>__<file>.yaml
URL so the rollup table client swaps the draft URL cleanly.

Wrong party = 422 with a clear error pointing at the SSR view as the
place to create the folder first. No auto-creation here — the rollup
is for filing deliverables/risks against existing packages, not for
spinning up new ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:14:37 -05:00
cef7188a77 refactor(convert): wrapper-in-image owns the sandbox; Go just exec's binaries
The bwrap engine + OCI engine that lived in internal/convert/runner.go
both leak isolation policy into Go code. Replaced with a single image-
side wrapper that drop-in-shadows pandoc and chromium-browser on PATH.
zddc-server's only contract with the image is now "exec.Command(name,
args) gets you that tool's behavior" — sandboxing, resource caps, and
namespace setup live entirely in shell scripts shipped by the image.

Architecture:
- zddc/runtime/zddc-cgroup-init runs at container start. cgroup v2's
  "no internal processes" constraint forbids a cgroup from having both
  children and processes; the init script moves PID 1 into a child,
  enables +memory +pids in subtree_control, then exec's zddc-server.
  Best-effort: degrades cleanly to "no resource caps" if cgroupfs
  isn't writable.
- zddc/runtime/zddc-sandbox-exec is the per-call wrapper, symlinked
  from /usr/local/bin/{pandoc,chromium-browser}. Creates a transient
  cgroup v2 (memory.max + pids.max), then bubblewrap-sandboxes the
  real binary at /usr/bin/<name>: --unshare-all, --ro-bind /usr,
  --proc /proc, --tmpfs /tmp, --clearenv. Caller's scratch dir comes
  in via ZDDC_SCRATCH env and is bind-mounted at the SAME path so
  absolute paths round-trip unchanged.

Go simplifications (~250 lines net deletion):
- Runner interface: Run(ctx, binary, stdin, scratchDir, cmd) — no
  ToolSpec, no mount list, no engine concept. Single localRunner
  implementation; bwrapRunner + containerRunner both deleted.
- health.Probe just looks up pandoc + chromium on PATH; Capabilities
  drops engine kinds.
- Convert.go: ToHTML/ToPDF write to a per-call scratch dir under
  TMPDIR and pass absolute paths; the wrapper bind-mounts the dir.
  No more "/tpl" / "/pdf" mount-point indirection.
- Config drops --convert-pandoc-image, --convert-chromium-image,
  --convert-engine, --convert-podman-socket (OCI engine gone) and
  --convert-cpus (CPU caps don't apply in the new model — wall-clock
  + memory + pids is the cap set). Defaults raised to match the new
  caps the user authorized: mem 512→1024 MiB, pids 100→256,
  timeout 30→60 s.

Image:
- zddc/runtime.Containerfile builds the production runtime image
  (alpine + bubblewrap + pandoc + chromium + font-noto). Two
  COPY statements pull in the wrapper scripts; ln -s symlinks the
  shadow names.
- bitnest dev image mirrors this layout under /var/lib/zddc-dev-build/.

Container privilege required:
- Nested bwrap needs the outer container to permit user + mount
  namespace creation + MS_SLAVE on root. The default seccomp +
  AppArmor profiles block all of these. Quadlet adds:
    --cap-add=ALL
    --security-opt=seccomp=unconfined
    --security-opt=apparmor=unconfined
    --security-opt=unmask=ALL
  Helm chart sets the equivalent via securityContext (capabilities.
  add: SYS_ADMIN, seccompProfile.type: Unconfined, appArmorProfile.
  type: Unconfined). Trade-off documented in AGENTS.md: zddc-server
  RCE now has near-root power within the container, but the bind-
  mount layout still bounds blast radius; bwrap is the real boundary
  between zddc-server and untrusted markdown.

Tests: convert_test.go fully rewritten for the new Runner signature.
Drops TestBwrapArgs_* (functionality moved out of Go) and
TestImageTag (no more image refs). All 15 Go test packages green.

Verified live on bitnest: pandoc --version round-trip exits 0
through the wrapper; MD→DOCX produces a valid Word 2007+ file
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:47:58 -05:00
847e082e6e feat(tables): Export CSV button in the table toolbar
Client-side download of the current view — filter + sort + column
order match what's on screen, values pass through util.formatCell so
dates / numbers / booleans render the same way they do in cells. RFC
4180 quoting; UTF-8 BOM so Excel detects encoding without an import
wizard. Sits next to "+ Add row" and shows for every table that
loaded with columns (no HTTP gate — the data is already in the
client), so MDL, RSK, SSR, and both project-level rollups all get
the affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:00:23 -05:00
73e34bed5e feat: per-party RSK + project-level SSR/MDL/RSK rollup tables
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:

  - SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
    new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
    through X-ZDDC-Op: ssr-rename, which os.Rename's the party
    directory so every row inside follows. Party name doubles as the
    folder name (no opaque IDs) and is path-derived on read.

  - MDL/RSK rollups list every deliverable / every risk across all
    parties with a derived `party` column; "+ Add row" is suppressed
    because party affiliation is ambiguous in the aggregate view.

All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:56 -05:00
351dc63cb4 feat(zddc): ResolveVirtualView resolver for project-level table aggregations
Models virtualreceived.go's request-time path-rewrite pattern. Recognizes
/<project>/{ssr,mdl,rsk}/... URLs and maps row reads/writes back to
canonical files inside <project>/archive/<party>/, so ACL evaluates
against the per-party chain and operator overrides live where the data
does. ListSSRParties and ListRollupRows feed listing-time synthesis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:28 -05:00
da4754b6ef feat(convert): bwrap engine as production default
Replaces the always-spawn-an-OCI-container model with a per-call
bubblewrap sandbox. Pandoc and chromium binaries are baked into the
zddc-server runtime image; each conversion runs them under bwrap's
Linux-namespace isolation. No daemon, no socket, no privileged outer
container, no OCI image pull at conversion time.

Why: the OCI engine paid ≈ 350 MB image pulls + 400 MB persistent
storage + ~300 ms per-conversion startup, plus required either an
on-host daemon socket (zddc-RCE → host-RCE in one hop) or nested
container privileges. bwrap gets the same sandbox properties
(--unshare-all, ro-bind /usr, tmpfs /tmp, clearenv, no-network) at
~5 ms per call and zero external dependencies. This is the same
primitive Flatpak uses for every app launch — battle-tested at scale
for "untrusted-input, short-lived, isolated."

Runner abstraction:
- `Runner.Run` signature: image string → ToolSpec{Image, Binary}.
  Both fields populated by entry points; whichever engine is
  installed reads the one it needs.
- `bwrapRunner` (new): assembles bwrap argv via `buildBwrapArgs`
  helper (testable in isolation), spawns bwrap with the binary.
- `containerRunner` (renamed conceptually to "legacy fallback"):
  unchanged behavior, still reachable for hosts that prefer OCI
  containers per conversion.

Probe order in health.Probe: bwrap → podman → docker. First hit wins.
Engine kinds in Capabilities: "bwrap" | "podman" | "docker". The
no-engine error message now lists all three.

Config (cmd/zddc-server):
- new --convert-pandoc-binary  / ZDDC_CONVERT_PANDOC_BINARY  (default "pandoc")
- new --convert-chromium-binary / ZDDC_CONVERT_CHROMIUM_BINARY (default "chromium-browser")
- existing --convert-pandoc-image / --convert-chromium-image kept
  for the OCI engine, doc updated to clarify they only apply there.
- --convert-engine helptext lists bwrap first.

Images:
- New `zddc/runtime.Containerfile` — alpine + bubblewrap + pandoc-cli +
  chromium + font-noto. Documents build/publish workflow.
- helm/zddc-server-prod/values.yaml.example: runtimeImage default
  switched to a placeholder for the new bundled runtime image; bare
  alpine NO LONGER works for /.convert (clearly called out in the
  comment).
- bitnest dev: /var/lib/zddc-dev-build/Containerfile mirrors the
  production runtime image. Quadlet at /etc/containers/systemd/
  zddc.container drops the podman-socket mount (no longer needed)
  and sets ZDDC_CONVERT_ENGINE=bwrap explicitly to avoid silent
  downgrades if a stray podman ends up on PATH.

Tests:
- convert_test.go: fakeRunner / recordingRunner now record ToolSpec.
- New TestToolSpecPopulation pins that both Image and Binary are
  filled by every entry point.
- New TestBwrapArgs_SandboxFlagsPresent / MountTranslation /
  RejectsBadMountSpec lock in the bwrap argv shape — a refactor that
  drops a hardening flag or misroutes a mount fails this loud.

Docs:
- AGENTS.md § "Server-side document conversion" rewritten around
  the bwrap-first model with podman/docker as legacy fallbacks.
- ARCHITECTURE.md convert reference updated.
- internal/convert package doc reflects the two-engine probe order.

Verified end-to-end on bitnest: probe reports
  engine=bwrap pandoc_binary=pandoc chromium_binary=chromium-browser
on startup. All 15 Go test packages green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:42:28 -05:00
19566360a6 ui: fix admin-mode frame; drop project-stage strip
Three UI cleanups against the admin/browse chrome.

Red admin-mode frame (shared/elevation.css)
  Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
  doesn't reflow content, so in tools that butt their content to the
  viewport edge (browse split-pane, archive grid) the frame painted
  on top of the first 3px of content.
  Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
  pointer-events:none; z-index:9200 }. The frame lives in its own
  fixed layer above all content, so it never overlaps or steals
  clicks; content layout is unchanged.

Project-stage strip (Archive · Working · Staging · Reviewing)
  Low-value chrome. Removed entirely:
    - delete shared/nav.js + shared/nav.css
    - drop the include from every tool's build.sh
      (browse, transmittal, form, archive, landing, tables, classifier)
    - delete tests/nav.spec.js
    - rebuild tables.html (the //go:embed'd baked-in copy)
  Project navigation already happens through the directory tree in
  browse and the URL bar; the strip duplicated breadcrumb information
  without adding capability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:39:35 -05:00
cff840e225 test: lock down elevation gate, .zddc write matrix, audit-log attribution
Four targeted test suites that pin the invariants exercised by the
preceding audit refactor. Closes the coverage gaps identified after the
admin-decider consolidation and the .zddc write-path fix.

internal/policy/principal_test.go (NEW)
  TestAllowActionFromChainP_TruthTable — 11 cases × 5 actions = 55
    assertions covering every (elevated × admin-at-level × action)
    combination. Pins the IsActiveAdmin short-circuit: bypass requires
    BOTH (in admins) AND Elevated; elevation alone confers nothing;
    empty email never matches.
  TestAllowActionFromChainP_AdminScopeDepth — root admin reaches every
    path; subtree admin matches in their own subtree; subtree admin
    does NOT match in a sibling subtree (the chain doesn't carry
    sibling admins lists).
  TestAllowActionFromChainP_BypassWinsOverWorm — elevated admin
    escape hatch in WORM zones, plus the negative control that an
    un-elevated admin does NOT bypass WORM.

internal/handler/auth_invariants_test.go (appended)
  TestInvariant_ZddcPutMatrix — 16 sub-cases across (root / project /
    subtree .zddc) × (root admin / subtree admin / non-admin /
    anonymous) × (elevated / un-elevated). Locks down which principal
    can PUT which .zddc.
  TestInvariant_ZddcDeleteMatrix — 5 DELETE cases.
  TestInvariant_UnelevatedAdminNoSilentBypass — 14 anti-bypass probes:
    every (admin-flavour × probe-path) tuple where an un-elevated
    admin must 403. Single bypass leak → loud test failure.

cmd/zddc-server/main_test.go (appended)
  TestDispatchZddcWriteRouting — full dispatcher path coverage:
    GET/HEAD route to ServeZddcFile (YAML or virtual placeholder);
    PUT/DELETE route through the .zddc-leaf carve-out into
    ServeFileAPI; intermediate .zddc.d/ segments still 404 at the
    guard.

internal/handler/middleware_test.go (appended)
  TestAccessLog_ChainAdminLevelAttribution — 7 cases pinning the
    forensic record: root admin → chain_admin_level=0, subtree admin
    in scope → chain_admin_level=N, subtree admin out of scope → -1,
    un-elevated admin → -1, non-admin → -1, anonymous → -1.
    Cross-checks active_admin == (chain_admin_level >= 0) so a future
    refactor can't desync them.

92 new sub-cases total. Coverage delta on the policy package:
76.1% → 87.2%; AllowActionFromChainP 0% → 100%;
activeAdminForRequest 7% → 68%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:29:43 -05:00
f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:07 -05:00