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>
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>
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>
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>
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>
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>
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>
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>
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>
The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for
everyone. Now an active (elevated) admin over its directory can browse it
in the file tree like any other zip: GET /.zddc.zip/ lists members, GET
/.zddc.zip/<member> extracts one, and a bare GET downloads it. Everyone
else — including the same admin un-elevated — still gets 404 for every URL
shape, which additionally closes a prior by-name member read (the old gate
only 404'd the bundle base, so /.zddc.zip/<member> leaked to any reader of
the root).
The dispatch gate now keys off the bundle segment anywhere in the path and
requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing
(fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to
an active admin, so non-admins don't even see the names under ?hidden=1.
Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so
browse already renders it as a navigable archive (tree.js isZip). Internal
apps.Bundle FS resolution never goes through dispatch, so it's unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A no-slash GET to a data file, in a directory whose cascade declares
views.file = {tool: form}, now serves the form editor bound to that file
(render-edit; POST goes to the canonical <file>.yaml.html update URL).
Gated on Accept: text/html so it only fires for browser NAVIGATIONS — the
tables client reads rows via fetch() (Accept: */*) and gets raw YAML
unchanged, and ?raw is an explicit bytes escape hatch. A directory without
views.file keeps serving raw bytes. Opt-in per subtree; presentation only
(ACL/WORM stay orthogonal and server-enforced).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).
Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
(classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
#table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
when present (server mode) and still walks the directory for ROW files; FS-
Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
passes the injected context through. Regenerated the embedded tables.html.
go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.
Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
serveSpecializedNoSlash now consults zddc.ViewAt(dir, "dir"): an explicit
`views.dir` in the cascade overrides the default_tool-derived app for the
no-slash directory URL. default_tool stays the sugar fallback (ViewAt returns
it when no views.dir is declared), so existing deployments are unaffected —
purely additive.
Also fixes the mergeOverlay trap (per the .zddc-policy-key checklist): added
Views to walker.go's per-level merge so views: survives cascade resolution at
default-driven paths (without it the key silently no-ops). Verified by a
defaults-path unit test (TestViewAt): default_tool/dir_tool surface via ViewAt;
an explicit views: entry overrides default_tool and declares a file shape.
go build + go test ./... all green. (Next: ServeView config injection from
.zddc.d/, the file→form shape, recognizer retirement, client + ./build.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the generalized view model: `.zddc` declares, per URL shape,
which tool renders and where its supporting config lives.
- ZddcFile.Views map[string]ViewSpec{Tool, Config}; shapes "dir" / "dir_slash"
/ "file". config is a filename resolved under <dir>/.zddc.d/. Pure data — no
behaviour; presentation/routing only (ACL/WORM/admin stay server-enforced).
- lookups.ViewAt(root, dir, shape): cascade leaf→root first-match, with
default_tool / dir_tool honored as sugar for dir / dir_slash (semantics
unchanged). No merged map — resolved per-shape like DefaultToolAt.
- cascade summary, isZero/is-empty checks, and validation (tool ∈ AppNames;
config a path-bounded plain filename). Client .zddc validator (preview-yaml.js)
gains a `views` key + `viewmap` case.
Additive only — nothing consumes Views yet (the generic resolver + dispatch
wiring + recognizer retirement follow). go build + zddc/handler tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings zddc/README.md in line with the apps-fetch removal:
- "Apps: virtual tool HTMLs" override section now describes the 3-tier local
resolution (on-disk file → <root>/.zddc.zip member → embedded); drops the
URL/channel/version spec forms, the _app/ cache, signatures, and the apps:
example.
- Remove the ZDDC_APPS_PUBKEY env-var row.
- Security invariants #4 (in two places) reframed: a tool HTML on disk or in
the site .zddc.zip is a full UI mount (write access = UI-mounting authority;
<root>/.zddc.zip = site-wide), with no fetch and nothing to sign.
- Federal gap analysis: SI-7 no longer cites apps URL fetches; delete the
whole "Code-signed apps: URL fetches (NIST SI-7)" subsection (feature gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the blanket "block every dot/underscore segment" dispatch guard
with a single reserved namespace, .zddc.d/, which is admin-only at every
depth. Everything else dot-prefixed is now ordinary ACL-governed content;
a leading dot only hides an entry from listings (UI), not from the ACL.
.zddc.d/ holds the bearer-token store, so it must stay closed even under a
broad operator grant (e.g. `*: rwcd`). The path-tree cascade has no
match-this-name-at-any-depth rule, so .zddc.d/ is gated by segment name via
a hard rule that overrides operator ACLs — on reads in dispatch (404,
existence-hidden) and on writes in authorizeAction (403 defense-in-depth
for direct callers). Token validation is unaffected: it reads
.zddc.d/tokens directly from the filesystem in ACLMiddleware, before the
HTTP-layer gate.
The segment match is case-insensitive (strings.EqualFold): ZDDC_ROOT may
sit on a case-insensitive filesystem (SMB/CIFS/Azure Files) where .ZDDC.D
resolves to the same dir, so a write to a case-varied path — e.g. a MOVE
destination header that skips dispatch's canonical case-folding — must not
slip past the gate and plant a forged token. The dispatch gate also runs
BEFORE the raw .zddc view so the reserve's own cascade
(/<dir>/.zddc.d/.zddc) is existence-hidden rather than leaked by
ServeZddcFile. Regression tests cover both.
To keep all bookkeeping inside the one reserve, relocate the last two
caches under it (both regenerable, no data migration): the apps cache
_app/ -> .zddc.d/apps/ and the per-directory MD-conversion cache
<dir>/.converted/ -> <dir>/.zddc.d/converted/.
New internal/handler/sidecar.go defines ReservedSidecar + the
HasReservedSidecar / ActiveAdminForSidecar predicates used by both the
dispatch read-gate and the write-path gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
browse: the party picker reads the ssr/ registry (the authoritative party
list) and creates at physical peer paths <project>/<peer>/<party>/…;
"register new party" writes ssr/<party>.yaml first (party_source: ssr).
stage.js + accept-transmittal.js repointed to the top-level workspace peers
(working/staging/incoming) — received/issued + plan-review stay under the
WORM archive.
tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE
level into the party subdirs CLIENT-side (works online AND offline), with
$party from the server-injected row content (or derived from the subdir
offline). Rows carry the <party>/ prefix so reads/edits hit the real
per-party path. The server just lists the peer root normally (party subdirs
+ synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows
are dropped in favour of this dual-mode client recursion.
Full Go suite + all 256 Playwright tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Repoint handler + dispatch tests to the top-level peer layout: register
parties via ssr/<party>.yaml where party_source gates writes; move
workspace paths out from under archive (incoming/working/staging/reviewing
+ mdl/rsk are top-level, archive/<party>/{received,issued} stay WORM);
rewrite SSR create (writes ssr/<party>.yaml, no archive folder) + SSR
rename (registry-only); accept-transmittal source incoming/<party>/<txn>;
plan-review scaffolds top-level reviewing/staging; tablehandler
classifyVirtualTableDir recognizes <project>/<peer>/<party> (depth-3) for
per-party mdl/rsk tables. Full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
availability_test: tools resolve via the peer cascade (classifier on
incoming/working/staging, transmittal on staging, tables on mdl/rsk/ssr).
tree_test: drop the abandoned per-user-home + folder-nav virtual tests;
add an mdl/ cross-party aggregate-listing test; repoint empty-when-missing
to the declared peers.
Repoint default-tool/history/canonical-folder/auto-own/virtual/declared,
role-grant, and WORM-zone expectations to the top-level peer layout: archive
is now blanket-WORM (DC = rc there), the workspace/register peers carry the
DC grants directly, and incoming/working/staging/reviewing/mdl/rsk/ssr are
physical peers. ensure_test repointed to top-level paths + the virtual-reject
test inverted (peers are physical now).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the project layout from "archive/ is the only physical dir + six
virtual aggregators" to a flat set of physical, party-partitioned peers:
archive/<party>/{received,issued} pure WORM (one rule, no exceptions)
incoming|reviewing|working|staging/<party>/ workspaces
mdl|rsk/<party>/*.yaml registers (cross-party aggregate at the
peer root, $party from the real subdir)
ssr/<party>.yaml submittal status register AND the
authoritative party registry
A party exists iff ssr/<party>.yaml exists; the new `party_source: ssr`
cascade key gates party-folder creation under every other peer (archive
included) — create <peer>/<party> only when the registry row exists, else
409. Registration is a plain create of ssr/<party>.yaml (no WORM gymnastics),
so archive/ stays purely WORM.
Server core:
- defaults.zddc.yaml rewritten to the flat-peer + WORM-archive + party_source
shape; every virtual: removed; mdl/rsk get document_controller rwcd.
- slots.go: projectPeers/IsProjectPeer; perPartySlots={received,issued}.
- party_source key end-to-end (file.go/walker/lookups/cascade) + PartyRegistered.
- ensure.go canonical-ancestors generalized to peers; virtual reject removed.
- virtualviews.go: deleted the virtual-URL resolver/types/regex; kept
ListParties (reads ssr/*) + repointed ListRollupRows (physical <peer>/*/*).
- fs/tree.go: mdl/rsk peer-root listing aggregates physical party subdirs
(replaces the subdir folder-nav); ssr flat; spec entries advertised.
- fileapi.go: deleted virtual PUT/DELETE rewrites; mkdir allowlist → peers;
partySourceGate on mkdir/PUT/move.
- virtualviewhandler.go → ServeInjectedRow ($party/name injected on read so
the tables client renders the column unchanged).
- ssr/form/table handlers repointed to real paths (SSR create writes
ssr/<party>.yaml; rollup create writes mdl|rsk/<party>/<file>.yaml; SSR
rename is registry-only); IsDefaultSpec recognizes the new spec locations.
- accept-transmittal source incoming/<party>/<txn> (+ PartyRegistered guard);
plan-review scaffolds top-level reviewing/<party> + staging/<party>.
- main.go dispatch: removed virtual-row GET + folder-nav 302; injects the
source column on register-row reads.
Non-test build is green. Test suites still assert the OLD layout (verified:
all current failures are stale expectations, not bugs) — the test rewrite,
browse/tables client updates, and the data-migration script follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate edit-history bookkeeping under the single reserved .zddc.d/
sidecar (where tokens + access logs already live), instead of its own
top-level .history/ dot-name:
- history.go: record + text history now write/read <dir>/.zddc.d/history/<stem>/
(was <dir>/.history/<stem>/). Const renamed .history → .zddc.d/history and
unexported (the only external user was the dispatch carve-out). The history
VIEWER endpoints (<record>.yaml?history=1, <file>?history=…) read it
server-side, so they keep working for anyone with read on the live file;
the raw store is bookkeeping, blocked by the existing dot-prefix guard.
- main.go: drop the .history GET carve-out (b9ebee7) — superseded; history is
reached via the viewer, not raw browsing. Reword the guard comment to
"reserve .zddc.d/ bookkeeping" (Part B will replace the blanket block with a
.zddc.d/ admin-fence).
- Delete dead .devshell references (the dev-shell was dropped from the chart):
guard comment, paths.go comment, test fixtures/cases (→ .zddc.d), and docs.
This is Part A of the approved plan: ship history in its permanent home so we
never migrate it twice. Tests updated to the new paths; the obsolete
TestDispatchHistoryReadCarveOut is removed (raw-block covered by
TestDispatchHidesDotPrefixedSegments, viewer by mdhistory_test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two cleanups from the hard-coded-vs-cascade audit:
#2 Centralize the canonical slot names. The lists {ssr,mdl,rsk,working,
staging,reviewing} and the per-party {incoming,received,issued,mdl,rsk,
working,staging,reviewing} were hand-written across ensure.go (×2),
fileapi.go (×2), virtualviews.go, lookups.go. New internal/zddc/slots.go is
the single registry with IsRowSlot/IsFolderNavSlot/IsVirtualAggregatorSlot/
IsPerPartySlot; virtualViewRE is built from it. Slot NAMES stay hard-coded
(they carry bespoke behavior) but now live in one place — adding/adjusting a
slot is one edit, not a hunt. Pure refactor; behavior unchanged.
#1 Make the history file-type selection cascade-driven. IsTextHistoryCandidate
hard-coded ".md"; now it matches the effective history_globs from the .zddc
cascade (default ["*.md"], widen per-deployment e.g. ["*.md","*.txt"]). New
ZddcFile.HistoryGlobs + mergeOverlay + PolicyChain.EffectiveHistoryGlobs +
HistoryGlobsAt, threaded through serveFilePut/serveFileMove/dispatch and
ServeTextHistory (now takes fsRoot). The history: bool still gates whether
snapshots are recorded; history_globs only says which file types qualify.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking a history snapshot in the tree 404'd: the dispatcher's dot-prefix
guard blocks every .-segment URL, and the preview fetch hit the raw
.history/<stem>/<snap>.md path. But .history is ACL-modeled content (it
inherits the shadowed file's .zddc chain), not infra like .devshell — so
the guard was redundant with permissions there.
Carve GET/HEAD of .history out of the dot-prefix guard: snapshots are now
fetchable as ordinary ACL-gated files (read the live file → read its
history). Writes into .history stay blocked, and the listing dot-filter
still hides it from default views unless ?hidden is set. Export
handler.HistoryDirName for the dispatcher.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the markdown edit-history store from content-hashed blobs +
log.jsonl to one self-describing file per save:
.history/<stem>/<ts>-<email>.<ext>
The filename IS the audit (colon-free UTC timestamp valid on SMB/Azure
Files + the authoring email); listing the directory is the history. No
sidecar log, no hashing. A byte-identical save is a no-op; a pre-existing
file lazy-seeds its current bytes (author "unknown", stamped at mtime).
Reverting copies an old snapshot back (records as a fresh save). Snapshots
are kept forever.
Fixes the 404 reading history: reads no longer require history to be
*currently* enabled — ServeTextHistory serves whatever .history/<stem>/
exists (empty list when none); the dispatch drops the EffectiveHistory
gate for reads. WRITES stay gated by the history: flag. (The 404 came from
the aggregator refactor turning history off on project-level working/,
which made already-recorded snapshots unreadable.)
Renames: an in-place rename carries .history/<stem>/ to the new name
(serveFileMove); a cross-dir move leaves it behind.
Defaults: history: true now ships on the three live-editing slots —
working, mdl, rsk — at both the project-level nodes and the per-party
folders. It's a .zddc cascade key, so operators override per project.
Records (.yaml in mdl/rsk) keep their separate record-history path.
Browse history viewer updated to the filename-based version id (id ←
sha). Tests rewritten for the per-file scheme + rename behavior + SMB-safe
names; HistoryAt defaults test updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
enumerateAccess always computed the global summary — every project
(EnumerateProjects) and every admin subtree (enumerateAdminSubtrees tree
walk) — and merely appended the path-scoped fields when ?path= was given.
The browse hovercard calls this per folder hovered, so each distinct folder
paid a full global enumeration for data it never reads.
Split the two: a ?path= query now returns ONLY identity + path_verbs/
path_is_admin/path_can_elevate_grant/path_roles and skips the tree walks;
the no-path call still returns the full global view for the profile page.
Verified all path-scoped consumers (browse hovercard, form, tables) read
only path_* fields; the global consumers (elevation, stage, plan-review,
accept-transmittal) all call without ?path=.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hovering a folder/file now shows "Your permissions" (the rwcda verbs you
hold there) and "Your roles" (the cascade roles you're a member of at that
location — e.g. document_controller, project_team). Roles are cascade-
scoped, so they can differ by location; this answers "does the system think
I'm a document_controller here?".
- server: RolesForPrincipalInChain(chain, email) resolves the caller's role
memberships at a path (honouring fences/resets, incl. embedded standard
roles); /.profile/access?path= now returns path_roles alongside path_verbs.
- browse hovercard: "Your permissions" from node.verbs (sync); "Your roles"
async-filled from /.profile/access?path= via zddc.cap.at (memoised).
Offline mode shows "local folder (filesystem)" and no roles row.
Tests: RolesForPrincipalInChain unit tests (member union, wildcard members,
non-member, fence-hides-ancestor-role, empty email).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>