Replace the project-level working/<email> "personal workspace" idea (too much
complexity for too little) with a simpler model on the virtual <project>/working/:
- EnsureCanonicalAncestors now materialises the working/ slot dir on disk the
first time real content is created beneath it (it stays a plain dir, never
auto-owned). ssr/mdl/rsk/staging/reviewing keep rejecting physical writes.
- Each <project>/working/<folder>/ a user creates gets an unfenced auto-own
.zddc (creator rwcda; the team inherits read+create-new, not w/d). history:
true still inherits in, so markdown drafts there are versioned.
- defaults grant project_team rc + document_controller rwc at working/ so users
can create their folders and the DC has authority throughout.
- A bare file DIRECTLY at the working/ root is reserved for the
document_controller: serveFilePut and serveFileMove reject non-DC writes/moves
there (isProjectWorkingRootFile + zddc.IsRoleMemberAt), independent of the ACL
verb since mkdir and file-PUT both authorise as ActionCreate. Users work inside
a folder; the DC creates files at the root or promotes one up with a MOVE.
Tests: ensure_test materialisation + plain-slot cases; fileapi_test DC-gate for
PUT and MOVE. The generic dispatch-routing test moves its ops into working/drafts/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).
- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
GET / Accept:application/json changed shape in the May-2026 reshape:
it returns listing.FileInfo entries (directory names carry a trailing
'/', and the array can include non-directory entries) instead of the
legacy ProjectInfo array (bare names). archive.html's multi-project
mode (?projects=A,B) intersected those server names against the
projectFilter parsed from the URL — which is slash-free — so every
listed project missed the intersection, projectFilter emptied, the
"you don't have access" banner showed, and nothing scanned: empty
projects dropdown, no parties/transmittal folders.
Normalise serverNames (and the projectTitles keys) to bare directory
names and filter the listing to is_dir entries before intersecting.
The scan in source.js already uses the slash-free projectFilter
directly, so this single normalization restores the whole flow.
Verified headless against a 2-project fixture, old vs new binary:
old -> projectFilter [], no-access warning, no parties rendered;
new -> projectFilter [182246,197072], no warning, ACME/BETACO parties
rendered. Reaches prod via the next zddc-server release (archive.html
is //go:embed'd).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app-HTML dispatch branch (/archive.html, /browse.html at root) gated
serving the tool shell on read permission for the root dir — which no
per-project-scoped user has — so the root-level multi-project archive view
(/archive.html?projects=A,B) returned 403 to anyone but a root-elevated
admin. The landing page (/) and ServeDirectory already treat the root path
as a public shell and filter data per-project; the app-HTML branch didn't
get the same bypass.
Skip the read gate when the tool's request dir is the root: the shell is a
static app carrying no data, and the tool's own per-project/per-dir fetches
stay ACL-gated (fs.ListDirectory filters per entry). Non-root tool paths
(/<project>/archive.html) keep their read gate.
Test: a non-root, un-elevated user gets 200 on /archive.html but still 403
on a project directory they can't read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
saveAllFiles() carried a 200ms inter-operation sleep "to prevent race
conditions" plus a 300ms post-error "settle" sleep. But saveFile() is fully
awaited and each iteration renames a distinct file, so the saves are already
serialized — the sleeps were the band-aid for an earlier missing-await bug
(the "ensure properly awaited" comment marks where that got fixed). Remove
both: correctness comes from the awaits, the operations are independent, and
Save All no longer pays 200ms per file (≈10s on a 50-file batch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
transmittal/js/validation.js had no in-browser coverage. Add a spec for
both halves: the live #tracking-number aria-invalid binding (whitespace /
underscore) and validateBeforePublish() — a clean transmittal passes; a
tracking number with spaces/underscores fails and focuses the field; a
per-file bad tracking number or revision is flagged by row.
(The earlier audit's "transmittal is untested" was inaccurate — it already
has paste/FS round-trip, drag-drop, and init-state specs; this fills the
validation gap none of them covered.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
- Note the offline gap: audit stamping, history, filename composition,
field_codes/locked, and folder_fields all run server-side; tools
opened offline (file:// / FS-Access) can't enforce them — record
writes need zddc-server. (AGENTS.md + ARCHITECTURE.md.)
- Upgrade notes for a pre-folder-binding deployment: strip the leading
dash from stored suffix values (the template supplies it now);
originator that differs from the party folder is rewritten on next
write; phase/area-using deployments already had overrides so the
default trim doesn't touch them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- editor.js: suppress edit entry for cells whose schema is readOnly
(folder-bound originator, server-managed audit fields) — mirrors the
$-prefixed synthesized-column guard. The server overwrites these, so
inline-editing them was misleading and the value was silently lost.
- save.js createRow: on 201, re-fetch the written row so server-derived
fields (originator from the party folder, the composed tracking
number's components, audit stamps) surface immediately instead of
staying blank until reload. Falls back to the local merge if the GET
fails.
- save.js createRow: handle 409 (duplicate composed tracking number)
with a clear message on the sequence cell instead of the generic
errored state.
Test: tables.spec.js — a readOnly column doesn't mount an inline editor
while a normal sibling still edits. The 409 + re-fetch paths go through
the in-dir create POST (formCreateUrl), which the file:// Playwright
harness can't intercept; both are covered by the server e2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
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>
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>
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>
.preview-pane__body was flex: 1 + display: flex; flex-direction:
column but without min-height: 0. The flex item's default
min-height is min-content (its natural content size), so when the
YAML editor's CodeMirror viewport carried many lines, the body
grew to fit the editor instead of letting the editor scroll
internally. The chain ran out of viewport before reaching the
editor's bottom edge; the body's own scroll bottomed out at a
height that still cropped the last few lines.
Adding min-height: 0 lets the body shrink to its flex-allocated
size so CodeMirror's internal scroll takes over correctly. Same
root cause as the standard flex+overflow papercut documented in
half the CSS guides on the internet — fine to add unconditionally,
no other consumers of .preview-pane__body care.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The persistent #statusBar strip held whatever last-action message
was written ("Loaded N items", "Created folder X", error text, …)
and stuck around indefinitely, overlapping content while adding
little value. Deleted the strip; existing statusInfo/statusError
call sites now thunk through window.zddc.toast (the shared toast
helper every tool already bundles).
- Same function signatures: events.statusInfo /
events.statusError keep working without touching the 70+ call
sites across app.js, download.js, events.js, etc.
- plan-review.js had its own private statusInfo/statusError pair
(duplicated the DOM write); updated to route through
zddc.toast as well.
- statusClear becomes a no-op — toasts fade on their own (5s
info, 8s error via cap-toast) and the toast helper's
single-toast policy guarantees only the latest is visible.
Removed: #statusBar div from template.html, .status-bar / .is-error
/ .is-info / --error / --info rules from base.css and tree.css.
Zero remaining `statusBar` or `status-bar` references in the built
browse.html. Full Playwright suite green (243/0/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Three modes again behave consistently after Part 3's per-entry
gating:
1. file:// (FS Access API picker) — fromHandle leaves verbs unset
(now undefined, not ""). The events.js Rename/Delete gates
skip the cap.has cascade check when typeof node.verbs is not
'string', so the items stay enabled per the original canMutate
contract.
2. Caddy file-server — fromServerEntry sees no verbs in the
listing and preserves undefined. Same skip applies; Rename /
Delete stay enabled but the underlying server will 405 the
POST/DELETE (same pre-Part-3 behavior). Markdown/yaml editors
still mount read-only via cap.has's writable fallback.
3. zddc-server — verbs is always emitted (possibly as "" for an
explicit zero grant). cap.has interprets the string and the
gates apply.
The previous "verbs ?? ''" normalisation collapsed (1)+(2) into the
explicit-zero case, which incorrectly disabled Rename/Delete in
offline mode. Tri-state verbs (string non-empty / string empty /
undefined) restores the intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brief subsection under "Permission model" explaining the three
server surfaces that feed front-end gating (verbs in listings,
/.profile/access?path=, missing_verb in 403 bodies) and the shared
client helpers in shared/cap.js. Records the hide/disable
philosophy and notes that transmittal + classifier are FS-API-only
so server-side gating doesn't apply to their UI controls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes to the form tool's submit path:
- Submit button hides when /.profile/access?path=<submission dir>
reports no 'c' verb. The form-status line surfaces a short
explanation so the user knows why the button disappeared.
- 403 on POST routes through zddc.cap.handleForbidden, which
renders an error toast naming the missing verb and offers
Elevate when the path-scoped view reports an elevation grant
covering it. The existing "You are not allowed to submit here"
status line still appears as the in-form indicator.
Also guards shared/cap.js's fetchAccess against file:// URLs —
calling fetch() on a file:// page logs a browser-level error that
shows up as test-runner noise. Short-circuiting to null lets
offline tools (browse on a picked folder, form opened standalone
from a file URL) silently degrade to "no path-scoped info" and
fall back to whatever existing gate they had.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two server-aligned signals on save paths:
- +Add row button: fetches /.profile/access?path=<current dir> via
zddc.cap.at() once on load; if path_verbs doesn't include 'c'
the button disables with a tooltip ("You don't have create
access in this folder."). Async race-window is the same as any
other path-scoped fetch — server still gates the POST so a
stale client gets a 403 toast on click rather than a silent
accept.
- 403 on save/create: previously fell into the generic
"http-error" bucket with a console warn; now branches into
zddc.cap.handleForbidden which renders an error toast naming the
missing verb. When the path-scoped view reports an elevation
grant covering that verb, the toast appends an Elevate button.
Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browse's row context menu and in-place editors now consult the
server-computed verbs string (via window.zddc.cap.has) before
enabling write/delete affordances:
- Rename… disables when the entry's verbs lacks 'w'.
- Delete… disables when verbs lacks 'd'.
- Markdown editor mounts read-only when verbs lacks 'w'.
- YAML editor mounts read-only when verbs lacks 'w' for regular
files, 'a' for the .zddc placeholder (matches the file API's
ActionAdmin gate at that URL).
Disabled menu items carry a tooltip naming the missing access
("You don't have write access to this item.") so the user discovers
which permission is missing rather than just seeing a greyed row.
shared/context-menu.js gains a `tooltip` field (string or fn(ctx))
that sets the row's title attribute.
canMutate() stays as the source-side gate (server vs FS-API
reachability, zip-member / virtual filtering); verbs gate composes
on top. Server-side ACL still has the final say if a stale client
ever tries the action.
cap.has() falls back to node.writable for 'w' when verbs is absent,
so offline FS-API mode keeps working without a server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small helpers under window.zddc.cap, wired into every tool's
build:
cap.at(path) — Promise<AccessView|null>. Fetches
/.profile/access?path=<urlpath> and
memoises per-path for the session.
Used by tools to gate top-of-page
affordances on path_verbs / path_is_admin
/ path_can_elevate_grant.
cap.has(node, verb) — boolean. Reads the listing entry's
verbs string for the named verb.
Falls back to node.writable for 'w'
when verbs is absent (offline FS-API
listings or pre-promotion clients).
cap.handleForbidden(resp, — parses a 403 response's JSON body for
opts) missing_verb and renders an error
toast. When opts.path is supplied AND
the path-scoped access view reports
path_can_elevate_grant covering the
missing verb, the toast appends an
"Elevate" button that flips the
elevation cookie and reloads.
Browse loader.js + tree.js carry the new verbs field through to the
node objects so context-menu gating can call cap.has(node, 'w'|'d')
without changing the legacy node.writable contract. New CSS rule
.zddc-toast__action styles the inline Elevate button.
Concatenation order: cap.js comes after toast.js + elevation.js so
the dependencies (window.zddc.toast, window.zddc.elevation) are
present at module-load time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Today v0.0.19 surfaced a real failure mode: varasys → codeberg push-
mirror is `sync_on_commit: true`, but a transient codeberg 504 mid-
push left 2 of 8 tags un-replicated. BMC chart's Dockerfile fetches
zddc-server-v<X.Y.Z> from codeberg (no egress to git.varasys.io),
so the bumped chart fired BMC pipelines that immediately failed at
`git fetch refs/tags/zddc-server-v0.0.19`. Mirror's next periodic
push (8h default) would self-heal — but by then dev was broken.
Make the stable-cut deterministic: before bumping the chart, force
the push-mirror via the Forgejo API and poll codeberg until all 8
lockstep tags are visible. Fail the job (and skip the chart bump)
if codeberg is genuinely unreachable after 5 min — operator triages
manually rather than triggering downstream builds against a stale
codeberg.
Uses ${{ github.token }} (Forgejo Actions auto-injected) for the
push_mirrors-sync API call. If that token turns out to lack admin
scope on this repo (Forgejo specifics around runner-token perms
vary), the failure will be a clear 401/403 on the curl — switch
to a dedicated CHART_FORGEJO_TOKEN-style secret then.
Local repro:
FORGEJO_TOKEN=$FORGEJO_TOKEN curl -X POST \
-H "Authorization: token $FORGEJO_TOKEN" \
https://git.varasys.io/api/v1/repos/VARASYS/ZDDC/push_mirrors-sync
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Forgejo runner is containerized; inside the container $HOME is
/var/lib/forgejo-runner (uid 1001's passwd entry), not /home/user.
So `$HOME/.config/zddc-signing/env` resolved to the wrong path inside
the runner and the fallback I added in b925dc5 silently no-op'd.
The runner quadlet bind-mounts /home/user/.config/zddc-signing/ at
the same absolute path inside the container, so an additional
explicit `/home/user/.config/zddc-signing/env` candidate covers
the runner. Order: $HOME first (operator's own shell or another
user's setup), then /home/user as the canonical operator location.
Verified inside the running container as uid 1001:
sourced /home/user/.config/zddc-signing/env
ZDDC_SIGNING_KEY=/home/user/.config/zddc-signing/key.pem
key readable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Forgejo runner daemon (deploy-release.yml's host runner) starts
non-interactively and doesn't source ~/.bashrc, so the signing key
wasn't reaching ./build despite being available to interactive
shells. The 0.0.18 stable cut surfaced this — the runner re-cuts at
the tag and `sign_release_artifacts` failed with
"ZDDC_SIGNING_KEY is unset" on every stable tag push.
Match the ~/.bashrc auto-sourcing pattern used for
~/.config/{codeberg,forgejo,github}/env, but inside the build
script. Self-sufficient for any execution context: interactive
shell (already covered by bashrc), Forgejo runner (now covered),
cron, anything else.
Canonical operator setup (one-time):
cat > ~/.config/zddc-signing/env <<EOF
export ZDDC_SIGNING_KEY=/home/user/.config/zddc-signing/key.pem
EOF
chmod 600 ~/.config/zddc-signing/env
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>