Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the project layout from "archive/ is the only physical dir + six
virtual aggregators" to a flat set of physical, party-partitioned peers:
archive/<party>/{received,issued} pure WORM (one rule, no exceptions)
incoming|reviewing|working|staging/<party>/ workspaces
mdl|rsk/<party>/*.yaml registers (cross-party aggregate at the
peer root, $party from the real subdir)
ssr/<party>.yaml submittal status register AND the
authoritative party registry
A party exists iff ssr/<party>.yaml exists; the new `party_source: ssr`
cascade key gates party-folder creation under every other peer (archive
included) — create <peer>/<party> only when the registry row exists, else
409. Registration is a plain create of ssr/<party>.yaml (no WORM gymnastics),
so archive/ stays purely WORM.
Server core:
- defaults.zddc.yaml rewritten to the flat-peer + WORM-archive + party_source
shape; every virtual: removed; mdl/rsk get document_controller rwcd.
- slots.go: projectPeers/IsProjectPeer; perPartySlots={received,issued}.
- party_source key end-to-end (file.go/walker/lookups/cascade) + PartyRegistered.
- ensure.go canonical-ancestors generalized to peers; virtual reject removed.
- virtualviews.go: deleted the virtual-URL resolver/types/regex; kept
ListParties (reads ssr/*) + repointed ListRollupRows (physical <peer>/*/*).
- fs/tree.go: mdl/rsk peer-root listing aggregates physical party subdirs
(replaces the subdir folder-nav); ssr flat; spec entries advertised.
- fileapi.go: deleted virtual PUT/DELETE rewrites; mkdir allowlist → peers;
partySourceGate on mkdir/PUT/move.
- virtualviewhandler.go → ServeInjectedRow ($party/name injected on read so
the tables client renders the column unchanged).
- ssr/form/table handlers repointed to real paths (SSR create writes
ssr/<party>.yaml; rollup create writes mdl|rsk/<party>/<file>.yaml; SSR
rename is registry-only); IsDefaultSpec recognizes the new spec locations.
- accept-transmittal source incoming/<party>/<txn> (+ PartyRegistered guard);
plan-review scaffolds top-level reviewing/<party> + staging/<party>.
- main.go dispatch: removed virtual-row GET + folder-nav 302; injects the
source column on register-row reads.
Non-test build is green. Test suites still assert the OLD layout (verified:
all current failures are stale expectations, not bugs) — the test rewrite,
browse/tables client updates, and the data-migration script follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Add GET /<path>/.zddc?effective=1 returning JSON with the composed
ZddcFile across the full cascade plus a per-level source list. The
.zddc file itself still serves only what's defined at that level
(YAML, the source of truth); the new query is inspection-only
(JSON, never written back). The virtual .zddc body's header
comment already pointed at this URL — now it's live.
Wire shape:
{ url_path: "/Project-1/archive/Acme/working/",
merged: { …ZddcFile JSON, composed view… },
sources: [ { level: -1, url: "<embedded>",
contributed: ["roles", "available_tools", "paths"] },
{ level: 0, url: "/.zddc",
contributed: ["acl", "admins"] },
{ level: 4, url: "/Project-1/archive/Acme/working/.zddc",
contributed: ["default_tool", "auto_own", …] } ] }
New zddc.EffectiveZddc(chain) walks chain.Embedded then
chain.Levels[VisibleStart..leaf] through mergeOverlay, and folds the
cross-level Roles union (via the existing lookupRoleMembers,
matching the runtime ACL evaluator's semantics). Returns
([]SourceEntry) listing each contributing level with its non-zero
top-level fields. The handler maps SourceEntry.Level to a directory
URL: -1 → "<embedded>"; 0..n → "/<seg/seg/.../>.zddc".
ACL gate is the same as the YAML view (read on the directory).
X-ZDDC-Source: virtual:effective so clients can distinguish.
Four tests cover the contract:
- BasicCompose: alice's root grant + project_team baseline from
embedded + the project's title all surface in merged; sources
include -1 (embedded), 0 (root), 1 (project).
- InheritFence: top-level inherit:false on /Closed/.zddc drops
every ancestor including the embedded baseline from sources.
- RoleMemberUnion: document_controller declared at root and
project unions members in merged.roles (matches the runtime
cross-level union the ACL evaluator performs).
- existing virtual-body tests still pass — they hit the YAML path,
not the JSON branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds cascade-driven schema + immutable audit history for the three table-style
record stores (mdl, rsk, ssr). Two new .zddc top-level keys carry the rules:
- field_codes: discriminated-union vocabulary (kind: enum|pattern|free) for
the components used to compose tracking-number filenames and constrain
record bodies. Map-merge across the cascade, mirror of apps: semantics.
- records: per-pattern rules (filename_format, field_defaults, locked,
row_field, row_scope_fields). Filename-pattern scoping lets the SSR rule
live at the party-folder level without bleeding onto mdl/rsk siblings.
PUTs to record YAML files route through a new WriteWithHistory orchestrator
(internal/handler/history.go) which:
- strips six client-supplied audit fields (created_at/by, updated_at/by,
revision, previous_sha) so the client can't forge them
- validates body values against the cascade-resolved field_codes
- enforces filename_format composition (URL basename must match body fields)
- checks locked: defaults (422 mismatch)
- archives prior bytes to <dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>
- stamps server-managed audit fields and writes the live file
History-before-live ordering preserves the prior version even on mid-write
crash. previous_sha forms a hash chain across revisions for tamper evidence.
The embedded defaults.zddc.yaml now declares records: entries for mdl, rsk,
and ssr.yaml. RSK rows carry the table-tracking components + row sequence
(filename = <table-tracking>-<row>); MDL rows compose to their own
tracking number; SSR records' identity is the party folder name.
GET <record>.yaml?history=1 returns a JSON list of prior revisions, ACL
gated identically to the live record. dot-segment rejection in
resolveTargetPath protects .history/ from direct client writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.
Schema:
paths:
"*": # literal-segment or "*" segment-wildcard key
paths: # recursive — each step matches one segment
archive:
paths:
"*":
paths:
incoming:
title: "demo"
Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.
Semantics:
- matchGlob: literal key first (case-insensitive on segment),
"*" wildcard fallback.
- mergeOverlay: top wins per-field on scalars; maps merge key-by-
key with top overriding; lists concat-dedupe; Paths replaces
(recursive walker threads it through naturally).
- inherit:false at any on-disk level drops accumulated ancestor
virtual sources AND zeroes chain.Embedded — the operator owns
every rule from that level outward.
- Behaviour is bit-identical when no .zddc declares paths:; the
walker reduces to the prior linear cascade.
Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.
Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.
New pieces:
1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
repo. Single source of truth for the baked-in baseline; intentionally
minimal in Phase 1 (just title + empty acl) so existing deployments
stay bit-identical until Phase 2 starts populating the schema.
2. //go:embed (defaults.go) bakes the bytes into the binary so
shipped deployments don't need the file. Operators who want a
starting point export with:
zddc-server show-defaults > /var/lib/zddc/root/.zddc
3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
layers in the embedded defaults as a baseline below the on-disk
chain. Consumers that want the full effective view consult both;
existing consumers that only read chain.Levels keep working
bit-identically (the new field is additive).
4. New top-level `inherit:` key on ZddcFile. Default true. Set
`inherit: false` on any on-disk .zddc to zero out chain.Embedded
— the operator owns every rule from that level outward. Useful at
the on-disk root to fully reject the embedded defaults; useful at
deeper levels for sandbox subtrees.
5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
dumps the embedded bytes to stdout — same shape as --print-rego.
No flag plumbing needed beyond the existing args walk.
6. Tests: parse-roundtrip on the embedded file, presence in chain by
default, inherit:false drops it, explicit inherit:true is a no-op
versus the default.
Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A .zddc may now declare `acl.inherit: false` to fence off ancestor
grants and roles from the descendant subtree — the "complete reset
plus add back" pattern operators want for vendor folders and other
narrowly-scoped subtrees. The cascade walker honors the deepest fence
in [0, toIdx] when evaluating any level at-or-below it, both for
GrantedVerbsAtLevel/EffectiveVerbsRange and for role lookup
(RoleMembers / lookupRoleMembers).
Federal/strict cascade mode IGNORES the fence — required by
NIST AC-6 ("ancestor deny is absolute; no leaf-level override"). So
inherit:false has no effect under strict mode and ancestor grants
remain visible. Operators running the federal Rego preset get the
same behaviour from external policy enforcement.
API surface: ACLRules.Inherit (*bool, nil = unset = inherit-true);
ACLRules.InheritsAncestors() bool; PolicyChain.VisibleStart(toIdx,
mode) int. The mode parameter is now threaded through
GrantedVerbsAtLevel, MatchesPrincipal, MatchingPrincipals,
RoleMembers, and lookupRoleMembers so role resolution is fence-aware.
Tests:
- file_test.go: parser round-trip for absent / true / false inherit
- inherit_test.go: VisibleStart (no fence, fence clamps, nested fences,
strict-mode override), EffectiveVerbs (fence hides ancestor grants,
strict-mode keeps them), RoleMembers (ancestor roles hidden by fence,
local redefinition still works)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.
See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.