Adds a Cancel button alongside "Rename file & reopen" that discards the manual
identity-field edits and restores the filename-derived values (leaving the rest
of the front matter + body untouched), then recomputes dirty + the cue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename-cue div used an inline display:flex, which outranks the `hidden`
attribute's [hidden]{display:none} — so fmWarn.hidden=true never hid it and an
empty yellow box showed whenever the cue had nothing to say. Control visibility
via style.display ('none'/'flex') instead of the hidden attribute.
Also surface a status line when sync-on-open rewrites the front matter to match
the filename, so the change isn't silent ("Front matter synced to filename —
review and save").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The four identity fields (tracking_number/title/revision/status) come from the
filename — the single source of truth that the register/WORM/ACL key off, never
the front matter. But they must stay in the front matter for the converter's
title block. Resolve the long-standing "front matter disagrees with filename"
nag without coupling the system to ZDDC naming:
- Sync-on-open: when the filename is ZDDC-parseable, mirror its identity into
the front matter on open; if that corrects anything the buffer opens dirty so
a save bakes it in. No-op for non-ZDDC names — the editor stays fully usable
on arbitrary directories, where the front matter is the sole source.
- A manual edit to an identity field is treated as a cue to RENAME the file
(the filename owns identity), not a value to keep: the old "filename wins,
ignored" warning is replaced by an explicit "Rename file & reopen" button
that saves, renames to the implied ZDDC name, and reopens it (server mode via
the ?file deep-link; FS-Access via the moved handle).
- Reword the RecognizedFrontMatter hints from "the filename wins on mismatch"
to "mirrors the filename — rename the file to change it".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
partySourceGate ran on every PUT/move at party-depth-or-below and rejected
with 409 whenever the party lacked a registry row — including edits of files
already filed under working/<party>/…. The gate is an ONBOARDING guard (don't
let a typo'd/unregistered party folder be introduced), not a write gate: once
the party directory exists on disk the party is established, so editing within
it must succeed. Allow when <project>/<peer>/<party>/ already exists; keep the
409 only for introducing a brand-new unregistered party.
This was surfaced by the browse markdown editor 409ing on save for an existing
file under a party folder whose ssr/ row was missing or differently-cased.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three .zddc previewer fixes reported against the browse YAML editor:
- Lint no longer flags valid keys. browse/js/preview-yaml.js TOP_KEYS had
drifted from the Go decoder (zddc/internal/zddc/file.go): party_source,
history, history_globs, records, auto_own_roles, received_path,
planned_response_date, planned_review_date, field_codes were all reported
as "unknown key". Add them with appropriate type tags plus an 'object'
case in checkValue for the free-form maps (records, field_codes).
- The ".zddc schema" pill is now clickable (↗) — opens the canonical JSON
Schema the lint mirrors at /.api/zddc-schema (no-auth, read-only).
- The synthetic virtual-.zddc header comment named an internal source path
(internal/zddc/defaults/'s paths: tree) that an operator can't act on. It
now names the operator-facing artifact: the built-in defaults bundle,
exportable/overridable as a root .zddc.zip via `zddc-server show-defaults`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The intro <p class="help"> was a 2–3 sentence paragraph; in a narrower
preview pane it wrapped to ~9 lines (~170px tall), pushing the Title field
far down. It was also redundant with the read-only "Structure & advanced"
section + the "Edit raw YAML" button. Tighten it to one concise line
("Project options. Structural keys are read-only — use Edit raw YAML."):
now 20px wide / 41px on a narrow pane (was up to ~170px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tables tool referenced --spacing-sm/md/lg (14×) but they were never
defined and used no fallbacks, so every padding/margin/gap collapsed to 0 —
table cells had no vertical padding and the table sat flush to the viewport
edges. Define --spacing-sm/md/lg (+ alias the --color-*/--radius-sm names the
tool uses) in shared/base.css, and give .table-main a clear left/right gutter
(padding: md lg). Fixes every tables view (profile/tokens/diagnostics/mdl).
Profile: clicking a project (or admin-subtree) row now opens that scope's
.zddc INFO FORM in the browse editor (via the ?file=.zddc deep link →
selects + previews the .zddc → schema-driven Title/Roles/Admins form),
instead of navigating into the project's files. Diagnostic rows still link
to their endpoints.
Validated in a containerized browser: 24px side gutters + padded rows;
clicking Proj → /Proj/?file=.zddc → the .zddc form editor. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
grid.js was classifier-only; make it embed ANY embeddable full-page tool the
cascade resolves as default_tool — classifier (incoming/), transmittal
(staging/), archive (the index) — as an iframe scoped to the current dir
(<dir>/<tool>.html). This is the browse-as-shell bridge from the ADR: browse
stays the top-level app and the heavy tools open in-pane (the gridView), so
navigating to staging/ or archive/ inside browse shows transmittal/archive
without leaving the shell, with ?view=browse falling back to the folder
listing (and the standalone tools still served directly at the no-slash URL).
EMBEDDABLE = {classifier, transmittal, archive}; tables/forms embed in the
preview pane instead, landing/browse don't self-embed. resolveViewMode keys
off grid.availableHere() (now generic). Validated in a containerized browser:
each dir embeds its tool, ?view=browse overrides to the listing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).
Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.
Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the mechanism the tokens + profile consolidation now rests on:
AGENTS.md gains a "Server-injected collections (apiActions)" section under
the Tables system (pre-assembled #table-context + the create/deleteRow/
rowNav layer, with server-side per-role gating), and the ARCHITECTURE ADR
marks step 2 done (/.tokens + /.profile render via the engine) and flags
that the remaining folds (archive/landing/transmittal) are feature-rich
PLUGIN migrations — not quick tables-fications.
Adds TestBuildTokensTableContext locking the contract: only the caller's
own tokens become rows, each row carries its id for the delete action, and
apiActions wires create (one-time secret) + per-row delete to /.api/tokens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the bespoke profile page. /.profile now renders through the shared
tables engine (header chrome incl. the profile menu) from a server-injected
context: the caller's "Effective access" — projects + admin subtrees — as
clickable rows (rowNav opens each), identity in the description, and an
apiActions "+ New project" (name → POST /.profile/projects, gated on
can_create_project; roles are added afterward by editing the project's
.zddc, which is now standing-editable). Super-admin diagnostics
(config/logs/whoami/effective-policy) stay discoverable as rows linking to
their unchanged endpoints — gated on IsSuperAdmin so a non-admin's context
never even names them.
Dropped as redundant/niche: the in-page theme picker (the header has the
theme button), the localStorage inspector, and the "editable .zddc" links
(those files are now standing-editable in browse).
Extends the generic apiActions layer (tables/js/api-actions.js) with
`fixed` constant fields (e.g. parent="/"), `required` field validation, and
`rowNav` clickable rows (capture-phase, so it beats the editor's per-cell
handlers). Rewrote TestServeProfileHTMLLayered to the new model (per-role
context correctness: no admin leak; super-admin diagnostics present) and
dropped the now-dead stripTemplates helper. Validated in a containerized
browser; full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the bespoke, chrome-less /.tokens page. It now renders through the
shared tables engine — getting the standard header (logo, theme, profile
menu) + declarative columns/filters for free — from a server-injected,
pre-assembled #table-context built from the user's tokens (Store.List).
New, reusable "tables over an API collection" primitive (tables/js/
api-actions.js): when the injected context carries an `apiActions` block,
it drives create (a modal form → POST, surfacing the one-time secret) and
per-row delete (→ DELETE) against a REST endpoint, and hides the file-model
toolbar affordances (+ Add row / Save). It deliberately does NOT touch the
file-save/row-ops machinery (ETag/conflict/row-file writes), so the secrets
surface stays on the existing tested /.api/tokens endpoints.
Server: handler.injectTableContextObj injects an arbitrary pre-assembled
context; EmbeddedTablesHTML() exposes the renderer to sibling handlers;
ServeTokensPage builds the token context (+ apiActions for /.api/tokens)
and serves the tables HTML, falling back to the legacy skeleton only when
the store or the tables renderer is unavailable.
This is the first dynamic/virtual-record collection rendered by the same
declarative engine + chrome as on-disk tables — no bespoke page. Validated
end-to-end in a containerized browser (list + create→secret + revoke);
tests/tokens.spec.js updated to the new UI; full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the agreed direction: browse becomes the single shell (header +
tree + preview pane), content tools become preview-pane plugins, and
server features (account menu, permissions) are progressive enhancement —
not a server-rendered header wrapping an iframed browse. Sketches the
plugin contract (handles/render/dispose + the ctx capability object that
abstracts server-vs-local read/write/verbs) and the incremental migration
path. Captures the model settled on with the user.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).
New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.
No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.
elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.
Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A directory's display: map (on-disk child name → friendly label) was read
only from the immediate on-disk .zddc, so the baked-in defaults could never
supply labels. Resolve it through the cascade instead (new zddc.DisplayAt:
embedded baseline + ancestor + on-disk overrides, deepest wins per key) and
declare the labels in the embedded project-level default
(defaults/_any_/.zddc):
archive→Archive, incoming→Incoming, working→Working, staging→Staging,
reviewing→Reviewing, mdl→"Master Deliverables List", rsk→"Risk Register",
ssr→"Supplier/Subcontractor Status Report".
On-disk names stay simple/lowercase; clients render display_name in their
place (browse already does). An operator's on-disk display: still wins per
key. Drops the now-unused readDisplayMap (folded into DisplayAt). Verified
in a containerized browser: /Proj/ shows all eight friendly labels, with
mdl/rsk/ssr still rendered as click-to-table leaves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLAUDE.md/AGENTS.md/ARCHITECTURE.md described the old "elevation gates
.zddc edit" model. Rewrite the elevation sections to the current model:
config-edit is a STANDING permission (IsConfigEditor — subtree admin or
`a` verb, no toggle, VerbA granted above the WORM clamp); elevation is
purely additive (IsActiveAdmin = admin AND Elevated, single bypass site,
guards WORM/destructive/out-of-scope only); the elevate cookie is now a
per-page session cookie armed by the on-page bottom-right toggle; the
.zddc.zip bundle is visible+editable to config-editors of its dir (not
wide-read); .zddc.d secrets stay locked; config is transparent via
read-ACL'd ServeZddcFile. Drops stale references (CanEditZddc, Max-Age=1800,
header-toggle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A directory whose cascade default_tool is "tables" (mdl/rsk/ssr and any
operator-configured table dir) now shows in the browse tree as a leaf with
a table icon — no expand chevron — and clicking it opens the tables tool in
the preview pane (an iframe scoped to the dir, mirroring grid.js's
classifier embed) instead of expanding/navigating into the folder.
Detection rides the cascade, not hardcoded names: the directory listing now
carries a per-entry default_tool hint (listing.FileInfo.DefaultTool, set via
zddc.DefaultToolAt for both on-disk children and the virtual canonical peers
mdl/rsk/ssr). Browse's util.isTableLeaf(node) keys off it; tree.js renders
the leaf, events.js routes its click/Enter to the preview (excluding it from
expand/navigate), and preview.js renders the iframe at the dir's NO-SLASH
URL (the default_tool route — <dir>/tables.html 404s for a virtual dir).
Server mode only (the hint is absent on file://, so offline folders stay
ordinary expandable dirs). Validated end-to-end in a containerized browser:
mdl/rsk/ssr are leaves, normal folders keep their chevrons, and clicking mdl
loads the tables view inline without navigating.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The config bundle followed the old elevation gate: only an *elevated* admin
could browse or edit it. Bring it in line with the standing config-edit
model — a subtree admin / `a`-verb holder over the bundle's directory may
browse AND edit it without toggling. Elevation stays purely additive.
activeAdminForBundle → configEditorForBundle (zddc.IsConfigEditor, no
Elevated). Gates both the existence-hiding visibility check and the
ServeZipWrite path. Deliberately scoped to config-EDITORS, not all readers:
one .zddc.zip packs many subtrees' policy into a single file, so wide read
would leak a tightened subtree's rules — per-level transparency is served
by ServeZddcFile (already read-ACL'd) instead.
Client: isEditableZipMember drops the isElevated() check — the server gates
bundle visibility on config-edit authority, so if a member is visible the
session can edit it.
Tests: TestDispatchBundleAdminView now expects an un-elevated admin to SEE
the bundle (non-editor reader still 404); TestDispatchBundleAdminWrite adds
an un-elevated config-editor write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing a .zddc you administer no longer requires toggling admin mode.
Elevation becomes purely additive — it only adds the WORM/destructive
overrides ("things you otherwise couldn't do"), never a prerequisite for
authority you already hold.
Mechanism: a new zddc.IsConfigEditor(chain, email) reports STANDING
config-edit authority — being a subtree admin (admins: cascade) OR holding
the `a` verb — without the elevation gate. InternalDecider.Allow grants
VerbA on that basis ABOVE the WORM clamp: config is not WORM-protected
data, and VerbA only ever authorises .zddc/.zddc.zip/role mutations, never
write/delete of records (those stay clamped + elevation-gated). The full
WORM/ACL bypass (IsActiveAdmin) is unchanged — still admins: + Elevated.
This flows for free to the client: EffectiveVerbsFromChainP loops
ActionAdmin through the decider, so /.profile/access + cap.has(node,'a')
light up the .zddc form editor with no client change, and ServeZddcFile
already gates raw .zddc reads on directory read ACL (config is visible).
A standing subtree admin can thus rewrite their subtree's policy
(admins:/ACL/roles) un-elevated — bounded to their scope (authority
cascades down only, never up), logged, and unable to touch WORM data or
secrets without elevating. That's "admin of X = owns X's policy."
Tests: new TestStandingConfigEdit (decider matrix incl. WORM-transcending
config-edit + data-write still gated); updated the old "un-elevated admin
cannot edit .zddc" invariants (TruthTable, ZddcPut/DeleteMatrix,
NoSilentBypass now scoped to WORM/out-of-scope, profile PathVerbs) to the
new model. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first section's heading top margin (.6rem) stacked with the intro
paragraph's bottom margin (.8rem), leaving ~1.4rem of dead space above
the Title label. Drop the heading's top margin for the first section
(new `tight` flag in section()) and trim the intro's bottom margin to
.5rem. Later sections keep their inter-section gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create actions duplicate the right-click context menu, so remove
them from the tree-pane toolbar. Reorder the toolbar so the Sort + Hidden
view controls sit ABOVE the autofilter box. Drops the now-dead toolbar
New-button click handlers and their create-gate enable/disable logic
(canCreateHere still gates the context-menu create items).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admins opt into admin powers via an on-page switch instead of only
?admin=true. The toggle renders ONLY for users the server reports
can_elevate, reusing each tool's existing header placeholder (or
creating one) and floating it bottom-right via fixed positioning.
Admin mode is now EPHEMERAL — scoped to the page you armed it on:
- the zddc-elevate cookie is session-scoped (drops the 30-min Max-Age)
- pagehide clears it, so navigating away / closing drops admin
Because a reload would race the pagehide-clear, every arm/drop path
(toggle, ?admin= URL, banner "Drop admin") now applies IN PLACE and
emits a `zddc:elevationchange` event. browse listens for it and
re-fetches the listing (server-computed verbs) + re-renders the open
preview, so editor affordances reflect the new elevation without a
manual reload.
Validated end-to-end in a containerized Chromium (Playwright over CDP)
against a local zddc-server: the toggle renders for can_elevate, arming
sets the session cookie + armed chrome, "Drop admin" and navigate-away
both clear it, and ?admin=true still arms via the same funnel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The primary editor for a .zddc is now a FORM, not raw YAML — so configuring a
project doesn't require understanding the cascade. preview-zddc-form.js fetches
the .zddc JSON Schema (/.api/zddc-schema) and renders:
- OPTION fields editable — title, admins (email list), roles (per-role member
lists, + add role). These are the "blanks an operator fills."
- STRUCTURE + unrendered keys (paths, worm, tools, behaviors, field_codes,
display, …) shown read-only in a collapsed "Structure & advanced" section
(classified by the schema's x-zddc-tier).
- An "Edit raw YAML" escape that hands off to the CodeMirror editor.
Save merges the edited option values back into the parsed document — preserving
every structure/unrendered key — and PUTs the YAML via util.saveFile, which
works for an on-disk .zddc AND a .zddc.zip bundle member (ServeZipWrite).
Edit authority is the existing gate (ActionAdmin 'a', or an editable bundle
member); non-admins get a read-only form.
Wired as the primary .zddc editor in preview.js (before the YAML plugin) and
into the unsaved-changes guard. Raw YAML remains the power-user fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Authoritative machine form of the GRAMMAR.md grammar: zddc.schema.json
(draft 2020-12) describes every .zddc key with type, enum, description, and
x-zddc-tier — "structure" (the project shape an end user shouldn't change:
paths, worm, *_tool, views, available_tools, auto_own*, party_source, history*,
records, acl, created_by) vs "option" (the blanks an operator fills: roles
members, field_codes, convert, display, admins, title, planned dates). This is
the contract a future .zddc form view uses to render option fields editable and
structure fields read-only.
Embedded (ZddcSchemaBytes) and served at GET /.api/zddc-schema for the client.
Test locks the tier classification.
Scope note: the schema uses $ref (recursive paths:) + patternProperties, which
the in-tree internal/jsonschema validator doesn't support — so it drives the
form/client now; wiring it as the SERVER validator (replacing validate.go's
hand-rolled checks) needs a $ref-capable validator and is a separate decision.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .zddc/markdown editors marked every zip member read-only. Add
util.isEditableZipMember (member of a .zddc.zip + session elevated) and let
those through canSave in both editors — so an elevated admin can open a bundle's
policy .zddc (or any member) and save it, which PUTs to the member URL where the
new server-side ServeZipWrite handles the in-place rewrite + in-zip history. The
server (bundle gate + active-admin) is the real authority; this just drives the
editor UX (mount editable, label "config bundle" instead of "read-only (zip)").
Content-archive members stay read-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A zip is random-access (unlike a streamed .tgz), so a member can be rewritten
in place. ServeZipWrite (handler/zipwrite.go) handles PUT (write/create a
member) and DELETE (remove) inside the .zddc.zip bundle: read the whole archive,
snapshot the prior member into an in-zip .history/<member>/<ts> + append a
log.jsonl audit line, mutate, then write a fresh zip and atomically rename over
the original (serialized on one mutex). After a write the policy cache is
invalidated so .zddc policy members take effect immediately, and the apps.Bundle
mtime-reload picks up tool-HTML edits.
Gated to active admins and to the .zddc.zip bundle only (dispatch's bundle gate
already 404s everyone else; content zips — transmittal/WORM packages — stay
read-only and 405). Writing into the in-zip .history/ is refused (append-only).
Also fixes a read collision: a .zddc member INSIDE a zip (e.g. a policy member,
URL ".../.zddc.zip/<dir>/.zddc") was being grabbed by the raw-.zddc-view handler
and 500ing; that handler now excludes ".zip/" paths so the zip intercept serves
the member.
Tests: writer round-trip (incl. wildcard member); dispatch create+overwrite,
policy-takes-effect, in-zip history audit, read-back, non-admin 404, content-zip
405.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The apps: .zddc key (channel/version/URL fetch + _app cache) was removed; both
files still described it as the tool-source override. Replaced with the current
model: drop a real <app>.html at the path, or add an <app>.html member to a
.zddc.zip (resolution: on-disk file → .zddc.zip member → embedded; no fetch).
AGENTS.md / ARCHITECTURE.md / zddc/README.md already reflected this.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-migration doc sweep across the repo-level references: defaults.zddc.yaml
(deleted) → the embedded per-depth tree (internal/zddc/defaults/), and
`zddc-server show-defaults` now exports a .zddc.zip policy bundle (per-depth
files) rather than dumping an annotated single YAML. Updates AGENTS.md,
ARCHITECTURE.md, CLAUDE.md, README.md, zddc/README.md. (GRAMMAR.md already
updated in the phase-6 commit.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.
- EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
.zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
delete individual members.
- Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
matrix is the ongoing behavioral guarantee, and it stays green).
- Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
- GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
directory (subtree mount; inherit:false + acl.inherit:false = island); the
shipped baseline is the embedded bundle at the root.
Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EffectivePolicy now reads, at every directory in the walk, an optional
<dir>/.zddc.zip policy bundle: its members are loaded into a PolicyTree,
Assemble()d into a nested ZddcFile, and merged UNDER the dir's on-disk .zddc
(most-specific human edit wins). Because Assemble produces an ordinary
paths:-bearing ZddcFile, the existing walker threads the bundle's deeper members
to descendants and honors inherit:false with zero new cascade logic — the
bundle is just another per-level policy source.
So a .zddc.zip dropped at ANY directory mounts a policy subtree there; combined
with inherit:false + acl.inherit:false in its root member it's a self-contained
island that ignores the site defaults (do-something-completely-different).
Member paths use "*" wildcards, resolved by the same literal-first matching as
paths:. A tool-HTML-only bundle (no .zddc members) contributes no policy.
Test: a bundle at /Proj/special grants only *@vendor.com (rwcd at the mount, r
at "*" descendants) and, fenced, blocks the embedded project_team grant that
still applies outside the island.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 — //go:embed all:defaults bakes the per-depth default tree into the
binary; EmbeddedPolicyTree() loads it (LoadPolicyTreeFromFS, generalized to any
fs.FS — embed, disk, or zip).
Phase 4 — PolicyTree.Assemble() folds the flat per-depth tree into the single
nested paths:-bearing ZddcFile the cascade walker already consumes, so the
walker is UNCHANGED. EmbeddedDefaults() now sources from the tree via Assemble()
instead of parsing defaults.zddc.yaml.
Proven behavior-preserving: TestEmbeddedTreeMatchesYAML asserts Assemble(tree)
deep-equals the legacy parsed defaults.zddc.yaml, and the Layer-2 matrix +
full suite stay green. defaults.zddc.yaml is kept only as that test's oracle
(deleted in phase 6). This same Assemble path is what an operator .zddc.zip
mounted at any level will use next (phase 5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for replacing the single embedded defaults.zddc.yaml with a
.zddc.zip policy SUBTREE mountable at any directory. defaults.zddc.yaml stays
live and authoritative for now — this is purely additive.
Phase 1 — author the per-depth default tree under internal/zddc/defaults/, one
focused .zddc per canonical folder (root, */, */archive, */working[/*], */ssr,
*/mdl[/*], */rsk[/*], */staging[/*], */reviewing[/*], */incoming[/*]). The
`_any_` directory is the on-disk stand-in for the "*" wildcard, so the repo
holds no shell-/go:embed-hostile literal "*" directories.
Phase 2 — PolicyTree (internal/zddc/zippolicy.go): a set of .zddc documents
keyed by member dir relative to a mount point, with "*" wildcards.
resolveTreeDir does literal-first, most-specific segment matching (mirrors the
paths: cascade); Along returns the governing member at each cascade level
root→leaf; LoadPolicyTreeFromDir loads the source tree (mapping _any_ → *).
This is the engine for "drop a .zddc.zip at any level"; inherit:false in a
resolved member makes that subtree a self-contained island (existing fence
mechanism, unchanged).
Tests: resolver matching mechanics; the split tree loads with the expected keys
+ content (data-level faithfulness — full effective-policy parity is the
Layer-2 matrix once the cascade is wired in Phase 4); Along ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidates the .zddc policy language — scattered today across ZddcFile struct
comments, defaults.zddc.yaml, and ARCHITECTURE.md — into one authoritative spec:
- document model + cascade (levels root→leaf, virtual paths:, fences) and the
rule that decisions resolve at the target's OWN dir (the bug class we hit);
- the decision pipeline: active-admin bypass → WORM mask → cascade ACL, plus
elevation + default-allow-on-empty-tree;
- ACL composition, with the two deliberately-different rules stated plainly
(role membership unions up the tree; permissions take the deepest match);
- a per-key reference table (type + cascade semantics + meaning) for all ~25
keys, including the mergeOverlay trap for adding new keys;
- reserved namespaces (.zddc.d, .zddc.zip);
- a reserved `when:` extension point for sandboxed, side-effect-free
expressions (CEL/expr-lang) — the safe alternative to raw JS, complementing
the existing OPA/Rego Decider seam;
- validation + the two executable backings (Layer 1 engine, Layer 2 matrix).
Policy-as-data: operators express behaviour in .zddc; the app enforces. Per the
chosen direction (formalize first; sandboxed expressions for the conditional gap).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The executable contract for the shipped defaults (internal/zddc/defaults.zddc.yaml):
~38 cells asserting who-can-do-what across the canonical project folders, routed
through the real decider (InternalDecider: cascade + WORM mask + active-admin
bypass) evaluated at the target's logical parent — the same decision the server
makes. Locks the document-control model so a change to the defaults OR the
engine that resolves them can't silently shift access. Storage-agnostic: if the
defaults later move into a project-root .zddc.zip of per-depth .zddc files, the
test is unchanged (it asserts effective policy, not where the bytes live).
Covers: no-create-at-project-root; DC/team/observer per-peer grants (working/
staging/reviewing/incoming/ssr); team rwc on mdl/rsk; archive WORM (DC
create-once, no write/delete; others read); elevated-admin bypass vs un-elevated
no-bypass; anonymous denied. Complements Layer 1 (engine-follows-policy):
policy.TestInternalDecider_CascadeScenarios + zddc/{acl,roles,worm}_test +
policy/parity_test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
authorizeAction walked `probe` up from the target's parent to the nearest
EXISTING directory before computing the ACL chain. For a create deep under a
not-yet-materialised canonical path — e.g. mkdir working/<party>/<name> when
working/ and working/<party>/ don't exist on disk yet — that walk skipped the
virtual working/ level and landed on the project root, where the embedded
grant is only `document_controller: rw` (no `c`). Result: a bona-fide
document_controller got 403 missing_verb=c creating in working/ (and party
registration would fail the same way on a fresh project where ssr/ doesn't
exist yet).
EffectivePolicy is virtual-path-aware — the paths: cascade resolves per-folder
behaviour for directories that don't exist on disk — so the chain must be
evaluated at filepath.Dir(absPath) directly. This applies the correct
per-peer grant (working/ → document_controller rwcda, project_team cr; ssr/ →
document_controller rwc) regardless of what's been physically created. Ancestor
restrictions (WORM zones, inherit:false fences) still apply because they cascade
through EffectivePolicy, so this is strictly more correct, never more permissive
than the cascade intends.
Regression test: a document_controller (role member, not admin, un-elevated)
registers a party and mkdirs under working/<party>/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
My earlier create-project flow wrote per-role verb grants (project_team: rwc,
…) at the PROJECT ROOT, which cascaded create/write across the whole project —
wrong. The project root is structurally locked to canonical peers
(rejectProjectRootMkdir), and the embedded defaults already grant each role its
per-FOLDER permissions ("None gets `c` here — create is granted only at the
specific peers below").
Project-create now writes role MEMBERSHIP only (document_controller /
project_team / observer) plus admins + created_by. Membership unions across the
cascade, so naming members at the project root makes the embedded per-peer
grants apply to them. No acl.permissions is seeded (the advanced field is still
an escape hatch). The dialog's "Guests" maps to the defaults' read-only
`observer` role (was a non-existent `guest` role that hooked no grants).
Per decision, MDL & RSK are now collaboratively editable: defaults grant
project_team rwc (create + edit, no delete) at mdl/ and rsk/ alongside
document_controller rwcd — the history: audit on both covers every change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Projects are always created at the deployment root, so the "Parent" dropdown
(and populateParentChoices) is gone — the client always POSTs parent:"/".
The Create-new-project dialog now collects members for the four project roles
— admins, document controllers, project team, guests — as simple email lists.
Server-side, each non-empty list becomes a roles:<name> entry plus a base
acl.permissions grant (document_controller→rwcd, project_team→rwc, guest→r);
an explicit advanced acl.permissions entry for the same key still wins.
The new project's .zddc now always records the creator: zf.CreatedBy = creator
email, and the creator is always included in admins: (deduped, first) so they
administer their own project from birth.
Tests: creator recorded + roles/permissions seeded; explicit permission
overrides the role default. Existing create tests still pass (creator-in-admins
is compatible with the explicit-admins-list case).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the doctype templates render $revision$, $status$, $tracking_number$
and $title$, so they belong in the recognised front-matter list — added them
(alongside the existing title) to convert.RecognizedFrontMatter.
These four are the document's canonical identity, sourced from the ZDDC
filename. Policy (chosen): the filename WINS — the rendered doc always uses the
filename-derived value (the HTML/PDF templates read it from the filename-derived
pandoc -V flags, which override YAML metadata). Front matter must not silently
diverge, so:
- their hints now read "set by the filename (the filename wins on mismatch)";
- the markdown editor shows a non-blocking warning when front matter sets one
of the four to a value differing from the filename (gated on a conventional
ZDDC filename — non-conventional files have no canonical identity, so front
matter stays free there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The markdown editor's YAML front-matter pane was a bare textarea, so authors
had no way to discover the keys the converter honours — notably `doctype:`
(report|letter|specification) and `numbering:`, which have no other source.
Add a single server-side source of truth, convert.RecognizedFrontMatter() +
convert.FrontMatterPlaceholder(), and expose it as JSON at GET /.api/frontmatter
(handler.ServeFrontMatterTemplate; read-only, no auth — leaks only documented
field names). The browse editor fetches it once (server mode) and sets the
front-matter textarea's placeholder to the greyed hint, so an empty pane shows
the recognized keys with one-line hints. It's placeholder-only: it inserts
nothing, vanishes on the first keystroke, and arbitrary keys remain free —
front matter is still passed through to pandoc untouched. file:// mode shows no
placeholder (conversion is server-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Export context-menu offered only md↔docx↔html (a symmetric set), so PDF
— which the server supports only as md→pdf — was missing. The markdown
editor's DOCX/HTML/PDF buttons hardcoded their own list, so the two could
drift.
Introduce a single source of truth in download.js: EXPORT_MATRIX mirrors
zddc/internal/convert.Convert() exactly (md→docx|html|pdf, docx→md|html,
html→md|docx), exposed as download.exportTargets(ext) + download.convertUrl().
The Export submenu and the editor's buttons both consume it, so a .md file now
offers PDF in the menu and the two surfaces can never disagree. PDF stays
markdown-only (no docx→pdf / html→pdf path exists server-side).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an "Export" item to the row context menu with a submenu:
- a folder offers ".zip" (reuses download.downloadFolder; works offline + server)
- an md/docx/html file offers the OTHER two formats, each triggering a
server-side conversion download via the new download.exportFile (builds the
sibling-extension URL and lets the browser pull the converted bytes). File
conversion is server-only, so it's hidden in offline (FS) mode; a zip is
already an archive and gets no Export.
menu-model's toMenuItem now passes a descriptor's `items` through as a submenu
(resolved against the captured browse ctx) instead of only emitting action rows.
Verified: 11/11 browse Playwright specs pass (incl. menu/context + Download ZIP);
a logic harness confirms the per-type submenu contents and that clicks route to
download.exportFile / downloadFolder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the conversion engine from markdown-source-only to a (from→to)
dispatcher, convert.Convert, supporting:
md → docx | html | pdf
docx → md | html
html → md | docx
- convertToMarkdown (docx→md, html→md): pandoc -t gfm --wrap=none with an
embedded inline-media.lua filter that base64-inlines mediabag images as data:
URIs, so the output .md is self-contained (markdown has no --embed-resources).
- convertToHTML now takes a source format: docx→html reuses the doctype template
and --embed-resources base64-inlines the docx's images automatically.
- convertToDocx takes a source format: html→docx embeds images natively.
- ToDocx/ToHTML/ToPDF are kept as the md-source entry points, delegating to the
shared internals. writeScratchFiles generalizes the old template-set writer.
Routing (converthandler.go):
- RecognizeVirtualConvert maps any target ext {md,docx,html,pdf} to the first
existing real sibling source by precedence (md←docx,html; docx←md,html;
html←md,docx; pdf←md). Real files still win (dispatcher stats first).
- ServeConverted accepts md; buildAndStore dispatches on (ext(src), format) via
convert.Convert; purgeConverted clears all derived siblings on any write.
Tests: per-direction command-shape assertions (convert) + recognizer matrix and
precedence (handler). Verified end-to-end with real pandoc (docx→md/html,
html→md/docx, base64 images). Full ./... suite green.
PDF stays markdown-only for now (docx/html→pdf would need a two-stage hop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Directory MOVE and DELETE were hard-rejected with 409 "not supported" for
everyone, so a folder could never be renamed, relocated, or removed — even
in admin mode. The browse menu offered Rename/Delete on folder rows, but
they failed at the server. This is exactly the restructuring admin mode
exists for (e.g. doing a layout migration by hand instead of a script).
serveFileMove: a directory source is now allowed when the principal is an
active admin (zddc.IsSubtreeAdmin) over BOTH the source subtree and the
destination's parent — a root admin covers all; a subtree admin within
scope. os.Rename relocates the whole subtree (bypassing the per-file
WORM/ACL gates on its contents, which is the point), and a move into the
directory's own descendant is refused (409). File moves are unchanged.
serveFileDelete: a directory target is now allowed for an active admin over
that subtree and removes it recursively (os.RemoveAll). Non-admins get 403.
Both relax the trailing-slash guard (the browse client sends folder ops with
a trailing slash) and decide file-vs-directory by stat. Directory ops skip
the If-Match precondition (a directory carries no ETag). Recursive deletes
are audited with a "(recursive)" marker. Non-admin directory ops now return
403 rather than the old blanket 409.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the server-side conversion section to describe the doctype templates
(report/letter/specification + partials), the front-matter template:/numbering:
selection, the .zddc.d/templates/ override cascade, and the known cache-on-
template-change limitation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The convert engine renders markdown→HTML/PDF through named doctype templates
selected by the document's `template:` front matter, with per-project/per-party
overrides.
convert package:
- embed.go now embeds the whole templates/ dir (all: prefix so _-prefixed
partials are included) as an embed.FS; drop the single viewer-template.html +
custom.css embeds. New TemplateSet type + DefaultTemplateSet(name) returning the
chosen doctype + its partials.
- ToHTML/ToPDF take a TemplateSet; writeTemplateSetToScratch materialises the
template + partials flat into the per-call scratch dir (pandoc resolves
$partial()$ from the template's own directory).
handler:
- converttemplate.go: templateNameFromFrontMatter (YAML front-matter scan,
sanitized to a bare basename) + resolveTemplateSet, which overlays
<level>/.zddc.d/templates/<name>.html overrides onto the embedded defaults,
walking docDir→fsRoot so a party dir beats the project-global dir. An override
may replace a doctype, a partial, or add a brand-new doctype.
- buildAndStore threads fsRoot + source into the html/pdf paths.
build: pandoc/templates/ is the single source of truth; shared/build-lib.sh
sync_pandoc_templates mirrors it into the embed dir on every build (cmp-guarded,
stale-pruning). convert.TestEmbeddedTemplatesMatchSource fails on drift.
Tests: drift + DefaultTemplateSet (convert); front-matter parse + cascade
override precedence (handler). Full ./... suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the single always-numbered viewer-template.html with a templates/
directory of named doctype templates that share partials:
- templates/_head.html — <head> + all CSS (numbering CSS now scoped behind a
body.numbered class instead of being applied unconditionally)
- templates/_doc.html — shared TOC-sidebar body (report/specification)
- templates/_scripts.html — shared JS
- templates/{report,specification}.html — TOC-layout doctypes
- templates/letter.html — single-column letterhead, no TOC
A document selects its template with `template: <name>` in YAML front matter
(default report) and turns on legal numbering with `numbering: true` (default
off). Pandoc passes both fields straight from the front matter — the numbering
toggle needs no converter code. Retire custom.css (folded into _head.html,
gated) and the old viewer-template.html.
CLI: convert md→html resolves templates/<name>.html (name from front matter,
sanitized, default report); convert-diff uses templates/report.html and no
longer passes --css=custom.css. README updated.
Server (zddc/internal/convert) still uses its own embedded copy and is
unchanged here; it migrates to this templates/ dir in the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shared/elevation.js toggles admin mode via the ?admin= URL param, but it's
client-side JS — it only runs on HTML tool pages, where it sets the sticky
zddc-elevate cookie. A raw endpoint (a directory's JSON listing, zip
browsing at /<…>.zip/, the file API) loads no JS, so ?admin=true was inert
there and such requests stayed un-elevated.
ACLMiddleware now reads the same ?admin= toggle directly: true|1|on|yes
elevates the request, false|0|off|no drops it (overriding the cookie for
that request). This is per-request only — the server doesn't set/clear the
cookie; elevation.js still owns sticky persistence on pages. Elevation
grants powers only to a caller who already holds admin authority (every
admin call site re-checks via IsActiveAdmin), so a non-admin's ?admin=true
sets the forensic flag but confers nothing.
Makes e.g. GET /.zddc.zip/?admin=true work for an admin without first
arming the cookie on a page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>