Commit graph

506 commits

Author SHA1 Message Date
8b690b782f feat(tables): configurable column option source — dropdown from a live registry
A table column can declare `options_source: <peer>` and the server fills its
`enum` from the live entries under <project>/<peer>/ — so the row editor renders
a dropdown of the current registry instead of free text. Generic + configurable
in the spec; no hardcoding.

- Server (tablehandler.go): resolveDynamicEnums + registryEntries resolve the
  peer directory (its *.yaml basenames + subfolders, sorted, dot/spec entries
  skipped) into the column enum at ServeTable time, before the context inject.
- Default risk register: add a `package` column with `options_source: ssr`
  (dropdown of the project's SSR packages) + the matching form property. The
  spec comment documents the key so operators can source other registries.
- Test covering the resolver (entries, skips, untouched columns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:36:42 -05:00
b11f165b26 feat(tables): mark mandatory fields + show why a row won't save, inline
- Header: required columns (from the row schema's `required` list) get a red
  `*` marker so mandatory fields are obvious.
- Save: before the PUT/POST, a client-side required-field check marks the empty
  cells and blocks the save with a clear reason; the server's 422 remains the
  authority and its messages surface the same way.
- Inline error row: when a row can't be saved (required-missing, 422 validation,
  409 duplicate, 403 permission, network, HTTP error), the reason now shows in a
  full-width message row directly beneath the offending row — not just a hover
  tooltip or the far-off status bar. Cleared on a successful save / reload.
- Editor row-indexing counts data rows only (tr[data-row-id]) so the inserted
  error rows never offset cell navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:29:55 -05:00
0d052a20c3 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-08 15:19:43 -05:00
ec9c9c72bc fix(landing): trailing slash on working/staging/reviewing project links
These three are virtual party-aggregator folders — the trailing slash serves
their dir_tool (the browse folder-nav listing of parties INSIDE the folder),
while the no-slash form served the browse tool scoped at the project level.
Land the user inside the folder. archive/ keeps the no-slash form (its
default_tool is the archive tool, which is the intended landing there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:16:22 -05:00
49e8ea4b4f fix(browse): markdown editor shrinks instead of overhanging; pop out opens the real editor
- Overflow: the preview pane's child (the markdown shell) was a flex item with
  the default min-width:auto, so the editor's wide internal min-content pushed
  the whole pane past the viewport's right edge. Add min-width:0 on
  .preview-pane__body and its children so the editor shrinks (and its own +
  the grid's minmax(0) scrolling takes over) — the pane never overhangs.
- Pop out: editor-type files (markdown, yaml/.zddc, code text) were popped into
  the lightweight preview window, which can't host the bundled editor — so
  markdown showed as raw <pre>. Now they open the FULL browse app deep-linked
  to the file (<dir>?file=<name>) in a new window, loading the real editor.
  HTML keeps its rendered popup; images/pdf/office unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:36 -05:00
af16b14a52 feat(browse): render previewable files (.html) safely, with a Source⇄Preview toggle
Renderable types (.html/.htm) now show RENDERED by default again — but in a
sandboxed iframe (no scripts, no same-origin) so arbitrary HTML is safe — and a
header toggle flips to the CodeMirror source view (and back). Non-renderable
text files open straight in CodeMirror as before; PDF stays an iframe; markdown
keeps its own rendered/source toggle.

- RENDERABLE map + per-node view mode (rendered default; remembers only the
  last-toggled node, so switching files resets). Toggle button sits by Pop out.
- The same-node render guard now allows a deliberate toggle through (prompting
  to discard if the source editor is dirty); clearPreview hides the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:44:24 -05:00
1bd73b1512 feat(browse): CodeMirror for all editable text files; drop the .zddc form; tidy access dialog
- Editor routing: the CodeMirror editor is now the general editor for editable
  text files that aren't markdown — yaml/.zddc keep schema lint + completion +
  hover; txt/csv/tsv/json/xml/html/css/js/log/ini/conf/… open as a plaintext
  code editor (line numbers, find, save) instead of a read-only <pre>. HTML now
  edits its source (was an iframe render); PDF stays an iframe. Mode is yaml-only
  (the vendored CM mode); others plaintext, lint gutter suppressed.
- Abandon the .zddc schema FORM (preview-zddc-form.js deleted): .zddc opens in
  CodeMirror. Guided dialogs (Manage access, …) are the front door for common
  tasks; CodeMirror is the full/raw surface. One fewer half-baked middle layer.
- Manage Access dialog laid out cleanly: grid rows (who fills + shrinks, level
  sizes to content) so long emails/levels never overflow; short level labels
  with tooltips + a one-line legend; box-sizing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:18:31 -05:00
74ffefa191 feat(browse): guided "Manage access" dialog — task-first, no raw YAML
First of the intent-driven actions that replace raw-YAML editing as the front
door. Right-click a folder (or the pane) → "Manage access…" → a dialog of
people + friendly levels; the raw .zddc editor is demoted to "Edit raw policy
(.zddc)…" as the advanced escape. Both gated by the same admin authority.

- browse/js/manage-access.js: reads the folder's on-disk .zddc, presents
  principals as View / Contribute / Edit / Manage (admins: membership), plus an
  "inherit from parent" toggle (uncheck = make private). Save maps levels back
  to verbs (r / rc / rwc / admins:), merges ONLY the access bits into the doc
  (every other key preserved), and PUTs. Unrecognised verb strings show as
  "Custom" and are preserved untouched.
- Menu: "Manage access…" (guided) is now primary; "Edit raw policy (.zddc)…"
  is the escape hatch.
- Self-contained modal + CSS; refreshes the listing on save.

Sets the pattern for the remaining operator tasks (role members, display label,
project convert vars, register a party).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:03:27 -05:00
d44e1b01bf feat(browse): unify the .zddc lint onto the JSON schema (baked, single source)
The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.

- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
  the exact file the server serves at /.api/zddc-schema. Synchronous + works
  offline (file://), where that endpoint is unreachable — drops the runtime
  fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
  pattern, properties, additionalProperties (false|schema), patternProperties,
  items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
  shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
  checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:30:17 -05:00
a13ce12a75 feat(browse): shared schema completion + hover docs; bring it to the .zddc editor
Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.

- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
  show-hint, debounced hover tooltip) + two providers:
    · flatProvider  — a fixed field list (front matter), with an exclude set.
    · schemaProvider — a JSON Schema walker that resolves nested key-paths
      through properties / additionalProperties / patternProperties and the
      recursive $ref:"#" .zddc uses for paths:; keys from object properties,
      values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
  schemaProvider on .zddc files — nested-key completion at every level, enum
  values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
  Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
  shared helper via flatProvider (excluding the filename-driven identity keys);
  the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:18:07 -05:00
2c877bd5b7 feat(browse): schema completion in the front-matter editor (keys + enum values)
Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.

- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
  browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
  report/letter/specification, numbering → true/false), exposed via the existing
  /.api/frontmatter JSON. Tracks the template set; keeps the server as the
  single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
  the filename-driven identity keys and keys already present) and enum VALUES
  after `key:`. Picking an enum key auto-opens its value list. Triggered on
  Ctrl-Space and automatically as you type (completeSingle:false — always a
  menu, never an auto-guess). Themed dropdown for dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:09:37 -05:00
84f93ba56d fix(browse): don't inject front matter on open; no conflict modal on rename
Two fixes to the markdown editor's identity handling:

- Sync-on-open now only RECONCILES front-matter identity keys the author
  already wrote (correcting a stale title/revision/…); it never ADDS them. A
  blank or new file opens blank instead of getting an auto-generated title at
  the top. The converter derives identity from the filename regardless, so an
  empty front matter needs nothing baked in. (Cancel/revert likewise only
  touches existing keys.)

- The "Rename file & reopen" flow force-writes the buffer (no If-Match) instead
  of routing through save(), which raised the conflict-resolution modal on a
  (spurious) 412. The rename is a deliberate user action and the identity edit
  that triggered it is consumed by the new filename, so we commit the buffer
  rather than interrupting with a merge dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:54:59 -05:00
cfe379d4f9 feat(browse): CodeMirror YAML editor for the markdown front-matter pane
Replace the raw <textarea> front-matter editor with a CodeMirror 5 instance —
the same editor family the .zddc previewer already uses (and already bundled in
browse, so no added weight). Gains: YAML syntax highlighting, line numbers, and
the shared js-yaml lint gutter; one consistent editing feel across the two YAML
surfaces.

- Mount fmCM (mode:yaml, lineNumbers, lint gutter, readOnly when not writable)
  into a host div; refresh on the next frame so it measures correctly.
- Route every front-matter read/write through fmCM.getValue()/setValue() and
  the change event (sync-on-open, dirty tracking, the rename cue, Cancel, save).
- The old textarea placeholder (recognised front-matter keys) becomes a greyed
  caption under the header whose tooltip carries the full key list — CodeMirror
  5 has no built-in placeholder. Arbitrary keys remain free either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:43:54 -05:00
f06ab5542d feat(browse): Cancel button on the identity-rename cue
Adds a Cancel button alongside "Rename file & reopen" that discards the manual
identity-field edits and restores the filename-derived values (leaving the rest
of the front matter + body untouched), then recomputes dirty + the cue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:35:28 -05:00
bed6231d6b fix(browse): empty rename-cue box; signal sync-on-open changes
The rename-cue div used an inline display:flex, which outranks the `hidden`
attribute's [hidden]{display:none} — so fmWarn.hidden=true never hid it and an
empty yellow box showed whenever the cue had nothing to say. Control visibility
via style.display ('none'/'flex') instead of the hidden attribute.

Also surface a status line when sync-on-open rewrites the front matter to match
the filename, so the change isn't silent ("Front matter synced to filename —
review and save").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:24:56 -05:00
242d25d55a chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
2026-06-08 08:10:42 -05:00
48b8199ff7 feat(browse): filename-authoritative identity in the markdown editor
The four identity fields (tracking_number/title/revision/status) come from the
filename — the single source of truth that the register/WORM/ACL key off, never
the front matter. But they must stay in the front matter for the converter's
title block. Resolve the long-standing "front matter disagrees with filename"
nag without coupling the system to ZDDC naming:

- Sync-on-open: when the filename is ZDDC-parseable, mirror its identity into
  the front matter on open; if that corrects anything the buffer opens dirty so
  a save bakes it in. No-op for non-ZDDC names — the editor stays fully usable
  on arbitrary directories, where the front matter is the sole source.
- A manual edit to an identity field is treated as a cue to RENAME the file
  (the filename owns identity), not a value to keep: the old "filename wins,
  ignored" warning is replaced by an explicit "Rename file & reopen" button
  that saves, renames to the implied ZDDC name, and reopens it (server mode via
  the ?file deep-link; FS-Access via the moved handle).
- Reword the RecognizedFrontMatter hints from "the filename wins on mismatch"
  to "mirrors the filename — rename the file to change it".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:10:00 -05:00
9320515214 fix(server): party_source gate must not block writes to an established party
partySourceGate ran on every PUT/move at party-depth-or-below and rejected
with 409 whenever the party lacked a registry row — including edits of files
already filed under working/<party>/…. The gate is an ONBOARDING guard (don't
let a typo'd/unregistered party folder be introduced), not a write gate: once
the party directory exists on disk the party is established, so editing within
it must succeed. Allow when <project>/<peer>/<party>/ already exists; keep the
409 only for introducing a brand-new unregistered party.

This was surfaced by the browse markdown editor 409ing on save for an existing
file under a party folder whose ssr/ row was missing or differently-cased.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 07:59:44 -05:00
d5ce4e1230 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-08 06:50:27 -05:00
0d7feb3468 fix(browse,server): sync .zddc lint keys, viewable schema pill, accurate virtual-source text
Three .zddc previewer fixes reported against the browse YAML editor:

- Lint no longer flags valid keys. browse/js/preview-yaml.js TOP_KEYS had
  drifted from the Go decoder (zddc/internal/zddc/file.go): party_source,
  history, history_globs, records, auto_own_roles, received_path,
  planned_response_date, planned_review_date, field_codes were all reported
  as "unknown key". Add them with appropriate type tags plus an 'object'
  case in checkValue for the free-form maps (records, field_codes).

- The ".zddc schema" pill is now clickable (↗) — opens the canonical JSON
  Schema the lint mirrors at /.api/zddc-schema (no-auth, read-only).

- The synthetic virtual-.zddc header comment named an internal source path
  (internal/zddc/defaults/'s paths: tree) that an operator can't act on. It
  now names the operator-facing artifact: the built-in defaults bundle,
  exportable/overridable as a root .zddc.zip via `zddc-server show-defaults`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:00:02 -05:00
a0e467200e fix(browse): shorten the .zddc form intro so it doesn't dominate the space above Title
The intro <p class="help"> was a 2–3 sentence paragraph; in a narrower
preview pane it wrapped to ~9 lines (~170px tall), pushing the Title field
far down. It was also redundant with the read-only "Structure & advanced"
section + the "Edit raw YAML" button. Tighten it to one concise line
("Project options. Structural keys are read-only — use Edit raw YAML."):
now 20px wide / 41px on a narrow pane (was up to ~170px).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:11:28 -05:00
14f8780dc5 fix(tables,profile): define spacing tokens (gutters + cell padding); profile rows open the .zddc form
The tables tool referenced --spacing-sm/md/lg (14×) but they were never
defined and used no fallbacks, so every padding/margin/gap collapsed to 0 —
table cells had no vertical padding and the table sat flush to the viewport
edges. Define --spacing-sm/md/lg (+ alias the --color-*/--radius-sm names the
tool uses) in shared/base.css, and give .table-main a clear left/right gutter
(padding: md lg). Fixes every tables view (profile/tokens/diagnostics/mdl).

Profile: clicking a project (or admin-subtree) row now opens that scope's
.zddc INFO FORM in the browse editor (via the ?file=.zddc deep link →
selects + previews the .zddc → schema-driven Title/Roles/Admins form),
instead of navigating into the project's files. Diagnostic rows still link
to their endpoints.

Validated in a containerized browser: 24px side gutters + padded rows;
clicking Proj → /Proj/?file=.zddc → the .zddc form editor. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:13:54 -05:00
1b9fec66b3 feat(browse): generalize the in-pane tool embed to fold classifier/transmittal/archive
grid.js was classifier-only; make it embed ANY embeddable full-page tool the
cascade resolves as default_tool — classifier (incoming/), transmittal
(staging/), archive (the index) — as an iframe scoped to the current dir
(<dir>/<tool>.html). This is the browse-as-shell bridge from the ADR: browse
stays the top-level app and the heavy tools open in-pane (the gridView), so
navigating to staging/ or archive/ inside browse shows transmittal/archive
without leaving the shell, with ?view=browse falling back to the folder
listing (and the standalone tools still served directly at the no-slash URL).

EMBEDDABLE = {classifier, transmittal, archive}; tables/forms embed in the
preview pane instead, landing/browse don't self-embed. resolveViewMode keys
off grid.availableHere() (now generic). Validated in a containerized browser:
each dir embeds its tool, ?view=browse overrides to the listing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:17:41 -05:00
d183de434d feat(profile): render the admin diagnostics (config/logs/whoami) as chrome'd tables
The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).

Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.

Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:23:23 -05:00
0c6396d246 docs+test: document the apiActions / server-injected-table primitive
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>
2026-06-06 16:55:20 -05:00
d9256050d2 feat(profile): render /.profile via the tables engine (access + create + diagnostics)
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>
2026-06-06 16:07:56 -05:00
2a05b7716c feat(tokens): render /.tokens via the tables engine + generic apiActions layer
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>
2026-06-06 15:09:40 -05:00
76087c861c docs(adr): browse-as-shell with preview-pane plugins (target architecture)
Document the agreed direction: browse becomes the single shell (header +
tree + preview pane), content tools become preview-pane plugins, and
server features (account menu, permissions) are progressive enhancement —
not a server-rendered header wrapping an iframed browse. Sketches the
plugin contract (handles/render/dispose + the ctx capability object that
abstracts server-vs-local read/write/verbs) and the incremental migration
path. Captures the model settled on with the user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:44:39 -05:00
ef849ab3fa feat(shared): replace floating elevation toggle with a header profile menu
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).

New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.

No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.

elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.

Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:43 -05:00
7f5a54f845 feat(server): cascade-resolved display: labels for the canonical project peers
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>
2026-06-05 17:48:46 -05:00
18d3aaebf0 docs: update the admin/elevation model to standing config-edit + additive sudo
CLAUDE.md/AGENTS.md/ARCHITECTURE.md described the old "elevation gates
.zddc edit" model. Rewrite the elevation sections to the current model:
config-edit is a STANDING permission (IsConfigEditor — subtree admin or
`a` verb, no toggle, VerbA granted above the WORM clamp); elevation is
purely additive (IsActiveAdmin = admin AND Elevated, single bypass site,
guards WORM/destructive/out-of-scope only); the elevate cookie is now a
per-page session cookie armed by the on-page bottom-right toggle; the
.zddc.zip bundle is visible+editable to config-editors of its dir (not
wide-read); .zddc.d secrets stay locked; config is transparent via
read-ACL'd ServeZddcFile. Drops stale references (CanEditZddc, Max-Age=1800,
header-toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:37:48 -05:00
9b20e4451f feat(browse): render default_tool=tables dirs (mdl/rsk/ssr) as click-to-table leaves
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>
2026-06-05 17:18:47 -05:00
70591dcfa6 feat(server,browse): .zddc.zip bundle visible+editable to standing config-editors
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>
2026-06-05 17:05:34 -05:00
bd219afeb7 feat(policy): config-edit is a standing permission, not elevation-gated
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>
2026-06-05 17:00:54 -05:00
252d3f173e fix(browse): tighten vertical space above Title in the .zddc form
The first section's heading top margin (.6rem) stacked with the intro
paragraph's bottom margin (.8rem), leaving ~1.4rem of dead space above
the Title label. Drop the heading's top margin for the first section
(new `tight` flag in section()) and trim the intro's bottom margin to
.5rem. Later sections keep their inter-section gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:14:03 -05:00
35a1547d33 feat(browse): drop New folder/file toolbar buttons, lift Sort/Hidden above filter
The create actions duplicate the right-click context menu, so remove
them from the tree-pane toolbar. Reorder the toolbar so the Sort + Hidden
view controls sit ABOVE the autofilter box. Drops the now-dead toolbar
New-button click handlers and their create-gate enable/disable logic
(canCreateHere still gates the context-menu create items).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:03:25 -05:00
4dfbc44d45 feat(elevation): on-page admin-mode toggle, ephemeral per-page
Admins opt into admin powers via an on-page switch instead of only
?admin=true. The toggle renders ONLY for users the server reports
can_elevate, reusing each tool's existing header placeholder (or
creating one) and floating it bottom-right via fixed positioning.

Admin mode is now EPHEMERAL — scoped to the page you armed it on:
  - the zddc-elevate cookie is session-scoped (drops the 30-min Max-Age)
  - pagehide clears it, so navigating away / closing drops admin

Because a reload would race the pagehide-clear, every arm/drop path
(toggle, ?admin= URL, banner "Drop admin") now applies IN PLACE and
emits a `zddc:elevationchange` event. browse listens for it and
re-fetches the listing (server-computed verbs) + re-renders the open
preview, so editor affordances reflect the new elevation without a
manual reload.

Validated end-to-end in a containerized Chromium (Playwright over CDP)
against a local zddc-server: the toggle renders for can_elevate, arming
sets the session cookie + armed chrome, "Drop admin" and navigate-away
both clear it, and ?admin=true still arms via the same funnel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:29:19 -05:00
cd645c53bb feat(browse): schema-driven .zddc form view (option fields editable, structure read-only)
The primary editor for a .zddc is now a FORM, not raw YAML — so configuring a
project doesn't require understanding the cascade. preview-zddc-form.js fetches
the .zddc JSON Schema (/.api/zddc-schema) and renders:
  - OPTION fields editable — title, admins (email list), roles (per-role member
    lists, + add role). These are the "blanks an operator fills."
  - STRUCTURE + unrendered keys (paths, worm, tools, behaviors, field_codes,
    display, …) shown read-only in a collapsed "Structure & advanced" section
    (classified by the schema's x-zddc-tier).
  - An "Edit raw YAML" escape that hands off to the CodeMirror editor.

Save merges the edited option values back into the parsed document — preserving
every structure/unrendered key — and PUTs the YAML via util.saveFile, which
works for an on-disk .zddc AND a .zddc.zip bundle member (ServeZipWrite).
Edit authority is the existing gate (ActionAdmin 'a', or an editable bundle
member); non-admins get a read-only form.

Wired as the primary .zddc editor in preview.js (before the YAML plugin) and
into the unsaved-changes guard. Raw YAML remains the power-user fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:47 -05:00
9b9d823a67 feat(zddc): .zddc JSON Schema (machine grammar) with structure/option tiers
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>
2026-06-05 14:54:07 -05:00
7b59d82cdb feat(browse): edit .zddc.zip bundle members in-place (elevated admin)
The .zddc/markdown editors marked every zip member read-only. Add
util.isEditableZipMember (member of a .zddc.zip + session elevated) and let
those through canSave in both editors — so an elevated admin can open a bundle's
policy .zddc (or any member) and save it, which PUTs to the member URL where the
new server-side ServeZipWrite handles the in-place rewrite + in-zip history. The
server (bundle gate + active-admin) is the real authority; this just drives the
editor UX (mount editable, label "config bundle" instead of "read-only (zip)").
Content-archive members stay read-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:30:48 -05:00
fcb8fc6cf1 feat(server): edit-in-place for the .zddc.zip config bundle, with in-zip history
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>
2026-06-05 14:28:06 -05:00
d878bc87e9 docs: drop stale apps: tool-source override (CLAUDE.md, README.md)
The apps: .zddc key (channel/version/URL fetch + _app cache) was removed; both
files still described it as the tool-source override. Replaced with the current
model: drop a real <app>.html at the path, or add an <app>.html member to a
.zddc.zip (resolution: on-disk file → .zddc.zip member → embedded; no fetch).
AGENTS.md / ARCHITECTURE.md / zddc/README.md already reflected this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:14:21 -05:00
51defb115a docs: sweep defaults.zddc.yaml → embedded default tree; show-defaults emits .zddc.zip
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>
2026-06-05 13:38:30 -05:00
1e0e403f1e feat(zddc): retire defaults.zddc.yaml; .zddc.zip is the policy carrier (phase 6)
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>
2026-06-05 11:35:21 -05:00
4681f2c358 feat(zddc): operator .zddc.zip mountable at any cascade level (migration phase 5)
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>
2026-06-05 11:29:12 -05:00
21f6883157 feat(zddc): embed default tree + assemble into cascade (migration phases 3-4)
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>
2026-06-05 11:22:59 -05:00
7e3dbe81aa feat(zddc): policy-tree resolver + per-depth default tree (migration phases 1-2)
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>
2026-06-05 10:40:34 -05:00
a84bdfdc58 docs(zddc): formal .zddc grammar reference
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>
2026-06-05 10:24:15 -05:00
bae8e1f79b test(policy): Layer-2 default-policy matrix — role × path × verb truth table
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>
2026-06-05 10:01:29 -05:00
3ac53fe894 fix(fileapi): authorize creates at the logical parent, not the nearest on-disk dir
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>
2026-06-05 09:39:19 -05:00