Commit graph

562 commits

Author SHA1 Message Date
af07fa4f80 Merge feat/md-history-perfile: admin URL toggle, per-save markdown history, slot registry/history_globs, history→.zddc.d/
Lands the v0.0.27 feature stack: ?admin= URL elevation toggle, the per-save
edit-history redesign (now stored under .zddc.d/history/), the canonical-slot
registry refactor + history_globs cascade key, and the .devshell removal.
2026-06-02 14:01:09 -05:00
e4e0fedaa2 refactor(history): store under .zddc.d/history/; drop .history carve-out + dead .devshell
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>
2026-06-02 13:48:41 -05:00
1eeaa1bd96 refactor(zddc): centralize canonical-slot registry; feat: history_globs cascade key
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>
2026-06-02 10:50:53 -05:00
b9ebee7551 fix(history): serve .history snapshots as ACL-gated content (carve dot-prefix guard)
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>
2026-06-02 10:16:12 -05:00
90a0901951 Merge feat/admin-url-toggle into md-history test branch
Combine the ?admin=true URL elevation toggle with the per-save history
redesign so both can be exercised together on bitnest before landing.
2026-06-02 09:59:47 -05:00
7ff78ef254 feat(history): self-describing per-save snapshots + readable-when-disabled + mdl/rsk/working defaults
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>
2026-06-02 09:51:23 -05:00
143e26f337 feat(elevation): toggle admin mode via ?admin=true|false URL param
Replace the header "Admin" checkbox with a URL toggle reachable from any
zddc-server page: ?admin=true arms, ?admin=false (or the red banner's "Drop
admin" button) drops. Reuses the existing zddc-elevate cookie as the sticky
state — it persists across navigation for its Max-Age window and is what the
server reads — so the param is stripped from the URL once applied (via
location.replace, so Back can't re-trigger it and it isn't bookmarked).

Arming is gated on /.profile/access can_elevate: a non-admin who types
?admin=true just gets the param stripped, never a misleading red border.
The red viewport border + banner (applyArmedChrome) are unchanged and still
reflect the cookie on every page load. The now-unused #elevation-toggle
header span stays hidden in templates (inert); render() removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:31:40 -05:00
28ebaa19cd chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
2026-06-01 13:31:01 -05:00
5ed4f8582b Merge feat/effective-access-hovercard: per-location permissions/roles, copyable read-only YAML, scoped access endpoint
- browse hovercard shows 'Your permissions' (verbs) + 'Your roles' (cascade
  roles) for the hovered folder/file; server adds path_roles to
  /.profile/access?path=.
- read-only YAML/.zddc viewer is selectable/copyable (readOnly:true, not
  'nocursor') without enabling admin mode.
- /.profile/access?path= returns only the path-scoped payload (skips the
  global project + admin-subtree tree walks).
2026-06-01 13:30:46 -05:00
1cf3f3a9b3 perf(server): scope /.profile/access?path= to the requested location only
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>
2026-06-01 13:23:22 -05:00
91e6d1ec82 fix(browse): keep read-only YAML/.zddc selectable & copyable
The YAML viewer mounted CodeMirror with readOnly:'nocursor', which removes
the textarea from focus and kills click-drag selection — so copying a
read-only .zddc snippet was impossible without flipping on admin mode to
make it writable. Use readOnly:true instead: non-editable but focusable,
so the user can select and copy. autofocus:false still keeps arrow-key
tree navigation intact until they click into the editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:13:20 -05:00
e258b0fa3d feat: show effective permissions + roles per location in the browse hovercard
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>
2026-06-01 11:12:39 -05:00
303bf7aade release: v0.0.26 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 22s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-06-01 10:52:10 -05:00
23f4edffdc Merge feat/aggregator-party-picker: aggregator party-picker + offline CRUD
Browse 'New folder/file' at a virtual aggregator root (working/staging/
reviewing) now prompts for a party and targets archive/<party>/<slot>/
(with a DC-gated '+ New party' option); the server 409s a direct mkdir in
an aggregator instead of shadowing. Reverts the project-level creator-owned
working/ folders in favour of uniform party-scoping. Local-directory mode
gains full create/edit/rename/delete via the FS-Access API with lazy
readwrite escalation.
2026-06-01 10:51:46 -05:00
96262a171e feat(browse): full create/edit/rename/delete in local-directory (offline) mode
Local folders are picked read-only, so create/edit/rename/delete were
either disabled or would fail. Now offline mode supports the same CRUD as
server mode, bounded only by what the filesystem grants:

- upload.js: ensureWritable() escalates the picked root to readwrite via
  the FS-Access permission prompt on the first mutation (one prompt, then
  granted for the session; requires the user gesture every caller has).
  makeDir/makeFile gain FS-API branches (getDirectoryHandle/getFileHandle
  {create:true} + createWritable) resolved through handleForDir; removeNode
  and renameNode (already FS-API) now escalate first.
- preview-markdown.js: the markdown editor's save escalates before
  createWritable, so editing a local .md persists.
- events.js: New folder / New markdown file menu items are enabled whenever
  there's a writable target (server, or a picked local folder) via
  canCreateHere(); rename/delete were already gated by canMutate (FS-API).
  The aggregator party-picker stays server-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:46:51 -05:00
56c3353f7b feat(browse): party picker for New folder/file in virtual aggregators
Creating a folder/file at a project-level folder-nav aggregator root
(working/staging/reviewing) used to error or silently shadow — the slots
are virtual and content is party-scoped. Now browse opens a party picker
that targets archive/<party>/<slot>/<name>, with a "+ New party…" option
(server-gated to the document_controller via the existing archive/ ACL).

- events.js: aggregatorRoot detection + openPartyPicker modal (mirrors the
  stage.js modal), createInAggregator routes the create to the canonical
  archive path; rewriteAggregatorPath handles right-clicking a party row
  shown in an aggregator listing so it never re-prompts.
- server: serveFileMkdir now 409s a mkdir inside an aggregator
  (rejectProjectAggregatorMkdir) with a pointer at archive/<party>/<slot>/,
  instead of letting the write fall through to an unreachable shadow dir.

Reverts the prior session's project-level creator-owned working/ folders
(per the design decision to make all three folder-nav slots uniformly
party-scoped): working/ is a pure virtual aggregator again like
staging/reviewing — drops the working/ history+auto_own+acl defaults, the
EnsureCanonicalAncestors working exception, the working-root document-
controller file gate (serveFilePut/Move) and zddc.IsRoleMemberAt. Per-party
archive/<party>/working/ keeps its own history + auto-own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:39:49 -05:00
0a7f8594c5 release: v0.0.25 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 21s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-06-01 10:07:02 -05:00
db96333718 Merge feat/md-history: markdown edit-history + creator-owned working folders
Server-side edit-history versioning for working-folder markdown (content-
addressed .history store + browse version-history viewer with diff/restore),
and the creator-owned working-folder model with document-controller-gated
files at the working/ root.
2026-06-01 10:06:25 -05:00
0d21c16102 feat(server): creator-owned working folders; document-controller-gated root files
Replace the project-level working/<email> "personal workspace" idea (too much
complexity for too little) with a simpler model on the virtual <project>/working/:

- EnsureCanonicalAncestors now materialises the working/ slot dir on disk the
  first time real content is created beneath it (it stays a plain dir, never
  auto-owned). ssr/mdl/rsk/staging/reviewing keep rejecting physical writes.
- Each <project>/working/<folder>/ a user creates gets an unfenced auto-own
  .zddc (creator rwcda; the team inherits read+create-new, not w/d). history:
  true still inherits in, so markdown drafts there are versioned.
- defaults grant project_team rc + document_controller rwc at working/ so users
  can create their folders and the DC has authority throughout.
- A bare file DIRECTLY at the working/ root is reserved for the
  document_controller: serveFilePut and serveFileMove reject non-DC writes/moves
  there (isProjectWorkingRootFile + zddc.IsRoleMemberAt), independent of the ACL
  verb since mkdir and file-PUT both authorise as ActionCreate. Users work inside
  a folder; the DC creates files at the root or promotes one up with a MOVE.

Tests: ensure_test materialisation + plain-slot cases; fileapi_test DC-gate for
PUT and MOVE. The generic dispatch-routing test moves its ops into working/drafts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:05:26 -05:00
5b8bcaed89 chore(embedded): cut v0.0.25-beta 2026-05-29 14:37:10 -05:00
c489a78f34 feat(server): enable edit-history on the project-level personal workspace
Per the working-folder design: <project>/working/<email>/ is each user's
personal workspace (public by default, owned by the creator who can privatize
via .zddc). The post-reshape defaults had stripped that node to a bare
aggregator, so personal markdown drafts got no history. Add history: true +
an auto_own (un-fenced) per-user-home rule to the project-level working node.
archive/<party>/working/ keeps its own history: true. Scope stays working-only
(staging/reviewing unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:36:56 -05:00
e58e66a49c chore(embedded): cut v0.0.25-beta 2026-05-28 14:20:21 -05:00
9972e6773a feat(browse): markdown version-history viewer with diff + restore
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).

- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
  prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
  GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
  the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:51 -05:00
de046360e6 release: v0.0.24 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 21s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 11:11:36 -05:00
fab44542bc fix(archive): normalize trailing slash in multi-project server listing
GET / Accept:application/json changed shape in the May-2026 reshape:
it returns listing.FileInfo entries (directory names carry a trailing
'/', and the array can include non-directory entries) instead of the
legacy ProjectInfo array (bare names). archive.html's multi-project
mode (?projects=A,B) intersected those server names against the
projectFilter parsed from the URL — which is slash-free — so every
listed project missed the intersection, projectFilter emptied, the
"you don't have access" banner showed, and nothing scanned: empty
projects dropdown, no parties/transmittal folders.

Normalise serverNames (and the projectTitles keys) to bare directory
names and filter the listing to is_dir entries before intersecting.
The scan in source.js already uses the slash-free projectFilter
directly, so this single normalization restores the whole flow.

Verified headless against a 2-project fixture, old vs new binary:
old -> projectFilter [], no-access warning, no parties rendered;
new -> projectFilter [182246,197072], no warning, ACME/BETACO parties
rendered. Reaches prod via the next zddc-server release (archive.html
is //go:embed'd).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:59:24 -05:00
d4f35d9927 release: v0.0.23 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 20s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 08:59:18 -05:00
ef70f5dcc1 fix(server): serve root-level tool shells without a root read grant
The app-HTML dispatch branch (/archive.html, /browse.html at root) gated
serving the tool shell on read permission for the root dir — which no
per-project-scoped user has — so the root-level multi-project archive view
(/archive.html?projects=A,B) returned 403 to anyone but a root-elevated
admin. The landing page (/) and ServeDirectory already treat the root path
as a public shell and filter data per-project; the app-HTML branch didn't
get the same bypass.

Skip the read gate when the tool's request dir is the root: the shell is a
static app carrying no data, and the tool's own per-project/per-dir fetches
stay ACL-gated (fs.ListDirectory filters per entry). Non-root tool paths
(/<project>/archive.html) keep their read gate.

Test: a non-root, un-elevated user gets 200 on /archive.html but still 403
on a project directory they can't read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:57:24 -05:00
9cec423361 release: v0.0.22 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 8s
Build + deploy releases / build-and-deploy (push) Successful in 19s
Build + deploy releases / notify-chart-prod (push) Failing after 7s
2026-05-22 07:28:42 -05:00
b1ef81077e chore(embedded): cut v0.0.22-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 9s
2026-05-21 17:10:23 -05:00
86d667309d fix(classifier): drop vestigial Save-All delays that masked a fixed await bug
saveAllFiles() carried a 200ms inter-operation sleep "to prevent race
conditions" plus a 300ms post-error "settle" sleep. But saveFile() is fully
awaited and each iteration renames a distinct file, so the saves are already
serialized — the sleeps were the band-aid for an earlier missing-await bug
(the "ensure properly awaited" comment marks where that got fixed). Remove
both: correctness comes from the awaits, the operations are independent, and
Save All no longer pays 200ms per file (≈10s on a 50-file batch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:07:28 -05:00
d91a37f356 test(transmittal): cover the publish-time validation gate
transmittal/js/validation.js had no in-browser coverage. Add a spec for
both halves: the live #tracking-number aria-invalid binding (whitespace /
underscore) and validateBeforePublish() — a clean transmittal passes; a
tracking number with spaces/underscores fails and focuses the field; a
per-file bad tracking number or revision is flagged by row.

(The earlier audit's "transmittal is untested" was inaccurate — it already
has paste/FS round-trip, drag-drop, and init-state specs; this fills the
validation gap none of them covered.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:49:23 -05:00
d0d8423ac6 test(handler): un-skip the profile existence-hiding invariant
TestInvariant_ProfileAdminEndpointsHideFromNonAdmins was skipped pending the
ServeProfile dispatcher refactor — which has since landed (ServeProfile in
profilehandler.go is the entry point, with an adminOnly wrapper that denies
with 404). Implement the test against it: non-admin, anonymous, and
un-elevated-admin callers must get 404 (never 403/200) on every admin-gated
sub-resource (/whoami, /config, /logs, /effective-policy, /reindex), so the
namespace can't be enumerated; an elevated admin gets through (/whoami,
/config positive control). Locks in the existence-hiding security property
that was previously unverified.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:21:37 -05:00
8ef2ce01d0 docs: record machinery is server-side only + pre-folder-binding upgrade notes
- Note the offline gap: audit stamping, history, filename composition,
  field_codes/locked, and folder_fields all run server-side; tools
  opened offline (file:// / FS-Access) can't enforce them — record
  writes need zddc-server. (AGENTS.md + ARCHITECTURE.md.)
- Upgrade notes for a pre-folder-binding deployment: strip the leading
  dash from stored suffix values (the template supplies it now);
  originator that differs from the party folder is rewritten on next
  write; phase/area-using deployments already had overrides so the
  default trim doesn't touch them.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:43 -05:00
9341c47937 feat(tables): read-only cells, show server-derived fields on create, clearer conflict
- editor.js: suppress edit entry for cells whose schema is readOnly
  (folder-bound originator, server-managed audit fields) — mirrors the
  $-prefixed synthesized-column guard. The server overwrites these, so
  inline-editing them was misleading and the value was silently lost.
- save.js createRow: on 201, re-fetch the written row so server-derived
  fields (originator from the party folder, the composed tracking
  number's components, audit stamps) surface immediately instead of
  staying blank until reload. Falls back to the local merge if the GET
  fails.
- save.js createRow: handle 409 (duplicate composed tracking number)
  with a clear message on the sequence cell instead of the generic
  errored state.

Test: tables.spec.js — a readOnly column doesn't mount an inline editor
while a normal sibling still edits. The 409 + re-fetch paths go through
the in-dir create POST (formCreateUrl), which the file:// Playwright
harness can't intercept; both are covered by the server e2e.

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

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

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

Now every record-write entry point converges on WriteWithHistory:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:23:12 -05:00
a6cb847f2f fix(browse): YAML editor cut off at viewport bottom
.preview-pane__body was flex: 1 + display: flex; flex-direction:
column but without min-height: 0. The flex item's default
min-height is min-content (its natural content size), so when the
YAML editor's CodeMirror viewport carried many lines, the body
grew to fit the editor instead of letting the editor scroll
internally. The chain ran out of viewport before reaching the
editor's bottom edge; the body's own scroll bottomed out at a
height that still cropped the last few lines.

Adding min-height: 0 lets the body shrink to its flex-allocated
size so CodeMirror's internal scroll takes over correctly. Same
root cause as the standard flex+overflow papercut documented in
half the CSS guides on the internet — fine to add unconditionally,
no other consumers of .preview-pane__body care.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:27:49 -05:00
889aa78589 refactor(browse): drop status-bar footer, route messages to toasts
The persistent #statusBar strip held whatever last-action message
was written ("Loaded N items", "Created folder X", error text, …)
and stuck around indefinitely, overlapping content while adding
little value. Deleted the strip; existing statusInfo/statusError
call sites now thunk through window.zddc.toast (the shared toast
helper every tool already bundles).

  - Same function signatures: events.statusInfo /
    events.statusError keep working without touching the 70+ call
    sites across app.js, download.js, events.js, etc.
  - plan-review.js had its own private statusInfo/statusError pair
    (duplicated the DOM write); updated to route through
    zddc.toast as well.
  - statusClear becomes a no-op — toasts fade on their own (5s
    info, 8s error via cap-toast) and the toast helper's
    single-toast policy guarantees only the latest is visible.

Removed: #statusBar div from template.html, .status-bar / .is-error
/ .is-info / --error / --info rules from base.css and tree.css.
Zero remaining `statusBar` or `status-bar` references in the built
browse.html. Full Playwright suite green (243/0/4).

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

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

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

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

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

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

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

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

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

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

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

## New: auto_own_roles

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

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

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

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

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

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

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

## Tests

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

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

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

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

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