zebflow 0.10.6

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
# Contract Stability Matrix

This matrix is Zebflow's north star for stable storage and data transfer. A
project created with a stable Zebflow release must remain readable, movable,
executable, and safely upgradable.

The rows are review areas. One area may contain more than one independently
versioned contract. Zebflow must not use one global format version for unrelated
formats.

## Status Words

- **Draft** means the page and its type were written together and the shape
  may still change without an amendment row; nothing outside its one owner
  reads it yet.
- **Critical** means a known conflict, missing identity, or missing version can
  make future compatibility unsafe.
- **Partial** means a format or mechanism exists, but one or more freeze checks
  are not enforced.
- **Review** means the area is recorded but its code and tests still need a full
  contract review.
- **Candidate** means every freeze check passes and the contract is ready to be
  declared stable.
- **Frozen** means Zebflow has published the contract and changes must follow the
  versioning contract.

No row becomes **Frozen** because it looks complete. It becomes frozen only
after its validator, compatibility behavior, migration behavior, and tests have
been verified.

## Priority Matrix

| No. | Priority | Review area | Contract or format today | Current version | Main owner | Current state | Required next decision |
| ---: | :---: | --- | --- | --- | --- | :---: | --- |
| 1 | P0 | Contract governance | Contract identity, compatibility, and release rules | Policy only | `docs/contracts/` | Review | Approve one lifecycle and freeze checklist for every contract |
| 2 | P0 | [Instance directory](./instance-directory.md) | Project repository, data, files, runtime, and generated paths | Policy only | `src/platform/adapters/file/mod.rs`, project services | Review | Applied and live-verified: `data/` splits into store, cache, recovery, and logs, with a fifth ratified tier, installed (`data/hub/`), holding `nodes/` and `rwe-libraries/`; a pre-tier project migrates transparently on first touch, atomically, idempotently. Document catch-up 2026-08-31, live-verified on a scratch instance: the tree an operator backs up by was missing six written paths and drew two unwritten ones. Added `.bootstrap/`, `platform/credential-key`, `platform/cluster-signing-key`, `platform/office-join-token` (all 0600 STORE; the credential key is catastrophic to lose and is now the one path the tree says so about), `platform/cache/hub-artifacts/`, `data/cache/agent_docs/`, and `files/tmp/runs/{request_id}/files/` — `lifecycle: temporary` FileRef bytes sitting inside the OBJECT tier, which is the location half of FileRef's temporary-cleanup open item. Removed `data/cache/web-assets/` (rule 10: a reader with no writer) and the `run/` contents line (no writer; the provisioning mutex lives beside the provisioning root, not the data root). `platform/cache/`'s annotation was wrong — the RWE script cache is `platform/rwe-script-cache/` — and is corrected. `services/hub-local/` keeps STORE with the reason written down: re-seeding only adds coordinates this release knows, so a package published by an older binary is never restored by deleting the shelf. The appendix line claiming the `hub-default` → `hub-local` rename was owed is stale and corrected — it shipped. Path containment enforced 2026-08-31: every caller-supplied relative path now passes through one helper, `infra::io::path::contained_rel_path`, which drops `..`, `.`, and any root or drive prefix before the join. The guard it replaces was `joined.starts_with(root)`, which cannot see traversal because `Path::starts_with` compares components without resolving them — so a hub install's `target_folder` of `billing/../../../../tmp/evil` wrote outside the project entirely. A hub *artifact* path that escapes is refused rather than relocated, because the package declaring it is telling us what it is. Remaining: the `data/logs/` retention window (`data/nodes/` → `data/hub/nodes/` shipped, first-touch atomic move) |
| 3 | P0 | [Project configuration](./kinds/project-configuration/README.md) | `zebflow.yaml` | `zebflow.com/v1` `ProjectConfiguration` | `src/contracts/kinds/project_configuration.rs`, `src/platform/services/project_config.rs` | Frozen | Reference contract complete; preserve its schema and use its freeze evidence for every later kind. Amended 2026-09-03: `spec.layout.assets` is `spec.layout.static` and `spec.layout.source` defaults to the repository itself, because a layout entry now only names where a kind is looked for — nothing is scaffolded, where before every entry was a directory created on every request. An empty declaration is legal and means the repository root; `static` is the one entry that may not be empty, its route being unauthenticated. See `layout.md` §9 |
| 4 | P0 | [Pipeline source](./kinds/pipeline/README.md) | Pipeline graph and `.zf.json` source | `zebflow.com/v1` `Pipeline` | `src/contracts/kinds/pipeline.rs`, `src/pipeline/model.rs`, `src/platform/services/project.rs` | Frozen | Preserve v1 and use its contract/runtime separation and recoverable activation sequence for later executable kinds. Frozen-default catch-up 2026-08-31: an omitted `spec.entry_nodes` means "Runtime derives roots from graph connectivity", and `engines/basic.rs` took `graph.nodes[0]` — the first node in *source* order, and only one of them, so a graph with two independent triggers ran half of itself and dropped the rest silently. `PipelineGraph::entry_node_ids()` now owns the derivation (every node with no incoming edge; source order only as the fallback for a wholly cyclic graph, since cycles are valid here), and both the engine and `project.rs`'s trigger-kind derivation call it. Reached only by graphs registered as raw contract JSON — the DSL parser fills `entry_nodes` in. Identity containment enforced 2026-08-31: `file_rel_path` arrives in the request body while authorization is decided from the project in the *route*, and `normalize_pipeline_file_rel_path` stripped a leading `/` and nothing else — so `../../../{other}/repo/{source}/api/x.zf.json` wrote, read, locked, and deleted an executable pipeline in another owner's project under the caller's own capability. The normalizer now contains the path, and every door — write, read, delete, activate, and the lock toggle, which had derived its own paths and had no guard at all — resolves through one `ProjectService::resolve_pipeline_paths` |
| 5 | P0 | [Dependency lock](./kinds/dependency-lock/README.md) | `zeb.lock` | `zebflow.com/v1` `DependencyLock` | `src/contracts/kinds/dependency_lock.rs`, dependency service, RWE library service, node registry | Frozen | Strict namespaces, discovery, repair, atomic Hub installation, migration, and two-instance transfer implemented and tested; source vocabulary restructured 2026-08-27 pre-release (`hub.local/public/static`, `direct.npm/file`, `project`) — code caught up 2026-08-28: the validator enum accepts only the new words (dead words refuse and the repair/refresh path quarantines and regenerates from requested state), lock writers record the real channel and package coordinate, entries resolve at `data/hub/rwe-libraries/{package_id}/…`, bundle keys are the dot-form coordinate, and the seeded official source order is flipped (static first). Serving catch-up 2026-08-31: "Integrity validation happens before an artifact becomes available to RWE or the node registry" was true of the node registry and false of RWE — `project_scoped_library_asset` read the lock only to derive an install root, then served any file beneath it without comparing `entry.integrity`. `verified_rwe_library_root` now re-hashes the locked entry bundle before any installed byte is served and refuses the whole package on drift (the lock pins the package as one artifact, so it verifies as one), answering 409 rather than quietly falling back to embedded bytes. Verification is remembered per entry file and reused only while the lock's recorded digest, the file's length, and its mtime all still match, which makes install, upgrade, repair, and hand-edit self-invalidating without any explicit clear |
| 6 | P0 | [Node definition](./kinds/node-definition/README.md) | Shared native, composite, and WASM node interface | `zebflow.com/v1` `NodeDefinition` | `src/contracts/kinds/node.rs`, `src/pipeline/model.rs`, node registry | Frozen | Preserve the implementation-neutral v1 interface; package release, artifacts, installation, and rollback remain owned by `NodeBundle` |
| 7 | P0 | [Node bundle](./kinds/node-bundle/README.md) | Installable one-node and multi-node source | `zebflow.com/v1` `NodeBundle` | `src/contracts/kinds/node.rs`, `src/platform/model.rs`, `src/platform/services/node_registry.rs` | Frozen | Kind ownership by consumption scope, run bindings, per-node WASM exports, scoped credentials, artifact existence, digest enforcement, local and Hub install through one review, and carried node interfaces are implemented and verified live; install remains recoverable rather than atomic by design |
| 8 | P0 | [File reference](./kinds/file-ref/README.md) | FileRef passed between native, composite, and WASM nodes | `__zf_type=file_ref` | `src/pipeline/nodes/shared/file_ref.rs` | Review | Spec sealed 2026-08-29: eleven required fields, `ref` opaque to all but its backend, eight `kind` values, digest `sha256:` + 64 hex. Code caught up 2026-08-31: all four producers (`file_ref.rs`, `engines/basic.rs`, `fs_thumbnail.rs`, and the `files/upload` API) emit the eleven fields and nothing else — `path`, `name`, `content_type`, and `url` are gone; `validate_file_ref` checks all eleven and closes the eight-value `kind` vocabulary; the upload API's FileRef gained the `sha256` and `kind` it was missing, so it is valid by its own contract; `engines/basic.rs`'s ninth `kind` value `text` was mapped to `binary` (`mime` already says `text/plain` exactly, and the eight are the promise) by deleting its private `infer_output_file_kind` in favour of the shared `infer_kind`, so one derivation feeds every writer; and `ref` opacity is now enforced at one door — `zebfs_rel_path`/`zebfs_rel_path_or_string` assert `backend == "zebfs"` before returning a local path, and all eleven call sites that used to join `ref` to the files directory unchecked go through it. Both help docs rewritten to the contract's shape. Contract corrected the same day: `trust` has four values in code (`untrusted`, `sanitized`, `generated`, `user`) and `origin` five, not the one and two the document claimed. Still open: `trust` is written and never read, temporary cleanup has no writer, remote streaming undefined |
| 9 | P0 | [Project file storage](./kinds/zebfs-acl/README.md) | ZebFS paths, object identity, lifecycle, and the ACL document | `zebflow.com/v1` `ZebFsAcl` | `src/zebfs/`, `src/platform/services/zebfs_acl.rs` | Review | Spec sealed 2026-08-29: `private`/`public_read`, `object`/`prefix`, longest match wins, missing or unreadable means private. Code caught up 2026-08-31: the enveloped document was already written, but the bare pre-contract shape was refused on read, which made every `/fs/…` request a 500 with no repair path — `set_access` reads before it writes, so it could not fix a file it would not open. `zebfs_acl::read` now falls back to a bare-shape reader (the `read_pre_contract_array` pattern from `mapserver/publish/registry.rs`), dropping the `version` counter the envelope replaced, and the next change rewrites the file enveloped with every old rule intact. Only the bare shape is accepted: a document carrying `apiVersion` is judged as an envelope, so a future `apiVersion` still refuses. Still open: who may change a rule, whether deleting an object removes its rule, whether a public prefix implies a public listing; and from before, multi-object transactions and cleanup ownership |
| 10 | P0 | [Project data stores](./kinds/database-schema/README.md) | Platform SQLite catalog; the project's own store and how its structure and starting rows live in `repo/` | SQLite migration `22`; `zebflow.com/v1` `DatabaseSchema` | `src/platform/adapters/data/sqlite.rs`, `src/platform/sekejap.rs`, `src/platform/policy/package.rs` | Review | Own store only (sekejap, sqlite; Postgres/MySQL capture deferred past stabilization): platform-written structure documents beside authored row files, replay at creation only, structure always read live from the engine and never mirrored. Agreed and not yet built: sekejap's structure document as plain SQL like SQLite's rather than a JSON envelope, an explicit save action in place of the write-on-every-DDL, a publish staleness refusal, and export-time selection of which row files ship. Still open for the store itself: backup/recovery, poisoned-lock recovery |
| 11 | P0 | [Project transfer](./kinds/project-bundle/README.md) | One envelope for every whole-project movement: declared classes, staged replace-per-class import, two-import identity rule | `zebflow.com/v1` `ProjectBundle` | `src/platform/services/project_transfer.rs`, `src/contracts/kinds/project_bundle.rs` | Review | Spec sealed 2026-08-28; code caught up 2026-08-28: strict envelope kind with golden fixture and negative tests; the transfer archive is the envelope and speaks classes (`store` is `data/store/` only — cache, hub, logs, and recovery stay home; `direct.*` lock bytes travel in `carried-dependencies/`); import verifies at staging (entry path safety, per-class tree digests, carried bytes against the archived lock) before one recovery swap per class, with reverse-swap rollback exposed; identity is provenance (repo-owned `zebflow.yaml`/`zeb.lock` retarget to the destination); superadmin platform import creates projects, auto-initiating the store for `repo`-only archives; unresolved `n.function.call` targets are the dependency report's fifth family — and as of 2026-08-31 an import actually runs it: all three import handlers call `dependency_lock.status` after a successful swap and return the whole report under `dependencies`, report and never refuse, so the fifth family is visible where the contract says it is rather than only where it is computed. Export completeness enforced 2026-08-31: `data/store/` was copied file-by-file off a running server, so a project's live SQLite databases travelled as a main file and a `-wal` captured at different instants — `instance-directory.md` rule 5 says a bundle is a restore image, and it was not. `kv.db` in particular is held open by the state bus, so a real export carried a 4 KB stub beside a 20 KB write-ahead log. Every SQLite file in the store is now checkpointed (`TRUNCATE`, falling back to `FULL`) before the class is copied, verified live: the exported `kv.db` opens alone and holds every committed row. `stage_archive`'s entry-name and symlink refusals are now shared with `n.fs.decompress`, so one archive rule covers both readers. Remaining: the hub `project_bundle` asset channel still ships the HubPackage layout rather than this envelope, and remote-office import destination and remote rollback stay unbuilt (row 14) |
| 12 | P0 | Runtime synchronization | Retired 2026-08-29. A project moves between any two Zebflow instances as a [`ProjectBundle`](./kinds/project-bundle/README.md), in any direction, and is created on the office that will run it. The master-to-worker copier in `src/infra/execution/sync/` (added 2026-04-10 in `fa3cb6d`) was a one-directional duplicate of that, with no verification and a destination wipe | none | removed; the surviving local half is `ClusterRuntimeSyncService::refresh_local_repo_state` | Retired | Code removal done 2026-08-30: the `RuntimeBundle` contract kind (registry now 17 kinds), `src/infra/execution/sync/` entirely, `ProjectRuntimeMaterializationRequest`, `build_bundle`/`apply_bundle`/`clear_repo_worktree` and their helpers, and both routes (`/api/internal/runtime/materialize`, `.../runtime/sync`) with every client that posted to them. `ProjectBootstrapPlan` survived the module — it is `zebflow.yaml`'s own bootstrap section, not a transfer format — and moved to `src/platform/model.rs` beside the other `zebflow.yaml` views, serialized shape unchanged and the ProjectConfiguration golden fixture still byte-for-byte. Rebuilding this office's own runtime metadata from its own working tree stays, unchanged. Remaining: remote placement now refuses instead of copying (row 14d), and the home page still offers an "Office target" dropdown for a placement that cannot be honoured |
| 13 | P1 | Reactive web protocol | RWE compile, render, event, and error messages | `rwe.v1` | `src/rwe/protocol.rs` | Partial | The source half is separated out as [`RweSource`](./kinds/rwe-source/README.md) (sealed 2026-08-30); still owed here: separate the compiled artifact from the wire protocol and test version rejection |
| 13b | P1 | [RWE source](./kinds/rwe-source/README.md) | One authored page, component, behavior script, or stylesheet | none; source file, no envelope | `src/rwe/core/compiler.rs` | Review | Spec sealed 2026-08-30: one default export per page, four import forms only, imports confined to the template root, add refuses a collision. Relative imports are refused in pages and in inlined component files, with the message naming the `@/` replacement; a hook used without an import in that file is refused too, so there are no implicit hook globals. A `react`, `react-dom`, `preact` or `preact/hooks` import is refused naming `"zeb/react"`, because this runtime is not React's — `usePageState`, `useRouter` and `cx` do not exist there, so the specifier would promise semantics the engine does not have. Stylesheets settled 2026-09-03: a page imports its CSS (`import "@/globals.css"`), a layout's import reaches every page built on that layout, and a stylesheet reached twice is emitted once — both verified by render rather than assumed. A new project ships `globals.css` at the repository root, tokens named with Tailwind's `--color-*`/`--font-*` namespaces, spent through arbitrary values (`bg-[var(--color-surface)]`) rather than custom classes. Still open: what a stylesheet may itself import, a root layout so "import it once" has somewhere to live, behavior-script import rules, and whether `cx`/`Link`/`forwardRef`/`memo`/`createContext`/`Fragment` must be imported the way hooks now are |
| 14 | P1 | [Hub package](./kinds/hub-package/README.md) | One published release: what installing it requires. Presentation, publisher identity, and provenance live in mutable storage beside it | `zebflow.com/v1` `HubPackage`; release version is independent | `src/contracts/kinds/hub_package.rs`, `src/platform/services/hub.rs`, platform models | Candidate | `spec.layout` aged past the week it was written, with cross-layout installs proven and one reader behind publish and both install gates; before Frozen, decide or record as deliberate each "Still to review" edge (unmapped install refusal codes, per-release retraction, uninstall destinations, layout-vs-carried-configuration disagreement); contract-code gap (2026-08-27) closed 2026-08-28: `services/hub-local/` (seed-only blessed shelf) and `services/hub-public/` (the one publish target, publisher/token/grant rows, created behind service enablement) are two roots of one store machinery, and no publish surface holds the local store handle; remaining: multi-office placement provisioning of `hub-public` on a remote state-owning office is not built |
| 14b | P0 | [Distribution](./distribution.md) | How a resource leaves one instance and arrives at another | Policy only | `docs/contracts/distribution.md`, `src/platform/services/hub.rs`, `src/platform/policy/package.rs` | Review | Decide git-source installation, promotion into the curated namespace, and whether official content is locked; apply each decision to every channel at once |
| 14c | P1 | [Hub repository index](./kinds/hub-repository-index/README.md) | `zebflow-repository.json`: what a static serving offers, where each release document sits, and which bytes are the right ones | `zebflow.com/v1` `HubRepositoryIndex` | `src/contracts/kinds/hub_repository_index.rs`, `src/platform/services/hub_repository.rs` | Candidate | Live static pass proven — ordered resolution across seeded sources, digest refusal by hash, review refusal with no project created; before Frozen, let the days-old kind read one real repository not produced by this codebase |
| 14d | P1 | [Offices](./offices.md) | Whether two instances are related at all: what an office keeps and accepts, the controller's three verbs, joining, break-glass, and exit | Not a kind; a top-level contract | `src/platform/services/cluster/`, `src/platform/web/mod.rs` registration routes | Review | Spec sealed 2026-08-29 (institutions/projects/data/execution/version kept; login and placement accepted; controller never on the data path; owner mapping at the door; host break-glass; defined exit). Code catch-up 2026-08-30: §4's kept institutions now hold on both sides of a join — the blessed seed and the first-boot defaults run regardless of role, proven live on a scratch office whose `services/hub-local/` holds the same 19 blessed packages as a standalone instance, byte-identical release document and artifact bytes. The defaults run for an office because §6 needs a local account to re-enable and §7 needs the office to be complete the moment it leaves; neither works on an office that never created one. `apply_bundle`'s repo wipe is gone with the copier (row 12), and placing a project on another office now refuses (`CLUSTER_REMOTE_PLACEMENT_UNBUILT`) rather than faking §9's unbuilt remote placement. Code catch-up 2026-08-30, §8's Token term: the shared environment secret is replaced by a per-office token shaped `zfjoin1:<office_id>:<secret>` — self-describing so the scheme can move to `zfjoin2` without old tokens becoming gibberish, and so the controller identifies the holder without searching by secret. Minting (`POST /api/platform/cluster/join-tokens`, superadmin; the CLI is the wrong home under §3a's frozen scope) creates the office record *before* the token, stores `sha256(secret)` and never the secret beside status, `created_at`, and `last_used_at`, in `office_join_tokens` in the platform catalog with a foreign key to `offices` (migration 17). Verification is constant-time (`subtle`), refuses a non-`active` status, and refuses a token whose office is not the office the request claims to be — so revoking one row locks out exactly one office and no other, proven live. Mutual proof: the office sends a nonce and the controller answers over it, and the office refuses a response that does not verify; controller-to-office calls carry the same construction under a distinct scheme word so neither direction replays as the other. (That answer was an HMAC keyed by the office's stored digest until the security catch-up below replaced it with an Ed25519 signature; the digest is no longer a key to anything.) The office stores its token at `platform/office-join-token` (0600, STORE tier, separate from the unbuilt credential encryption key of row 14e because the two have different lifecycles); the environment variable supplies a first join only, and variable-versus-stored disagreement refuses to start rather than overwriting either. No migration from the shared secret, pre-release: a value that is not `zfjoin1:` shaped is refused with the mint named. SHA-256 rather than Argon2 at rest is deliberate — the secret is 256 machine-random bits with no dictionary to slow, and it is re-verified on every heartbeat from every office. Code catch-up 2026-08-30, §2's third verb and §8's Identity-writes term: an operator authenticated on the controller now reaches an office without knowing any password there. The mechanism is one more scheme word over the key that already exists — `zfjoin1v:<office_id>:<identity>:<expires_at>:<nonce>:<proof>`, the proof an HMAC-SHA256 over the other four fields keyed by that office's stored secret digest, beside `zfjoin1` (office proves itself) and `zfjoin1c` (controller proves itself), so no direction replays as another and every field is authenticated: an editable office id would let one office's vouch open another and an editable expiry would not be an expiry. The office verifies with the secret it already holds and contacts nobody, which §3's "the controller's death costs logins, never execution" and §6's reasoning about the unreachable party together require rather than merely permit — proven live by redeeming after killing the controller. TTL is 120s: the honest path is mint → redirect → redeem, so the number is set by clock skew between two hosts and not by what the flow needs, and it is the whole allowance with no second leniency window; it is far under RFC 6749 §4.1.2's ten-minute ceiling for an authorization code because a vouch travels in a URL with no back-channel exchange. Single use is a row in the *office's* `platform/catalog.db` (STORE tier, beside sessions), claimed by one `INSERT OR IGNORE` on the nonce so two simultaneous redemptions yield one session and one refusal; rows past their own expiry are pruned in the same call, which bounds the table at the number of vouches redeemed inside one TTL window rather than the number ever redeemed, and loses nothing because the expiry check refuses those anyway. Revoking an office's token kills vouching for it with no separate code — minting loads the same record and refuses the same non-`active` status that stops a heartbeat — with one honestly bounded gap: plain revocation is the controller declining to use a key it still holds, so a vouch minted moments earlier stays redeemable at that office for at most the TTL; rotation kills it cryptographically, since the office's secret no longer derives the digest the controller holds. On accepting a vouch the office issues its own ordinary session cookie, the same one `POST /login` issues, so every downstream handler is unchanged. Judgement recorded rather than invented: a vouch **may** name an identity the office has never held, and creates it — §5's owner-mapping rule governs *joining a non-empty office*, a different moment, while §4 makes the controller's identity "the normal door" and §5's ordinary case is a fresh office holding nobody, §8 presumes the write by requiring it be logged, and §9 records inserting an administrator as "accepted and logged today". Two conservative halves go with it: a created account is `superadmin` per §2's "for administration" but gets a 32-byte random password that is hashed and discarded unread, so it is reachable by vouch and never by local login; an account that already exists keeps the role the office gave it, never escalated. Every one of those writes is a row in `office_identity_writes` in the office's own catalog — STORE and not BOUNDED, because the record of who was inserted into your accounts is the last thing that should quietly expire, and not a project's `data/logs/`, which is BOUNDED, 30-day, and project-scoped — read at `GET /api/platform/office/identity-writes` by the office's *own* superadmin with the controller uninvolved, which is the load-bearing half of §8's term. Surface: `POST /api/platform/cluster/offices/{office_id}/vouch` and `GET /cluster/offices/{office_id}/open` on the controller (both superadmin; the vouch names the session owner and there is no field for naming anyone else), `GET /office/vouch?v=` and `POST /api/office/vouch` on the office, and an "Open office" button on each remote office card so reaching an office is one action. Migration 18 adds both tables. Code catch-up 2026-08-30, §4's login term, §6 and §7 — the last three terms of the treaty, built as one loop because none of them is safe alone. A joined office refuses local password login: `AuthService::login` is gated ahead of the credential check, so the same refusal comes back for a correct password, a wrong one, and a name the office has never held, and a caller who cannot get in learns nothing about who is here. The refusal names the three facts an operator needs — this instance is joined, the controller's identity is the way in, host access re-enables local authority — and both commands that act on it. The state is `<data-root>/platform/office-join-token` existing, read on every attempt and deliberately **not** `ClusterRole`, which comes from which server-mode word was typed: a joined office restarted as plain `zeb/react` would otherwise forget it was joined and reopen the door, and starting in a different mode is not leaving. Break-glass (`zeb admin break-glass [<owner>]`) and detach (`zeb admin detach`) are offline commands against the data root, joining `zeb admin reset-password` under the noun that already exists for offline recovery, with its probe and its refusal to write behind a running server — §6 names filesystem access as the protection, as it is for `pg_hba.conf`, so a route would be reachable by exactly the population the closed door excludes and would need the credential that is missing; `interface.md` §1 freezes the verbs and grows the nouns, §3's Group 3 is offline maintenance run when the server will not start, and the alias set stays at three. `office` would be the better noun and is unavailable, because `zeb office` is a Group 1 server mode and `zeb office detach` would be one word meaning two things. Break-glass **re-enables local authority and does not detach**: §6 says the first and §7 makes leaving a separate act, and the only reading that keeps both sentences true is that an office repaired at 3am is still its controller's office in the morning. Its record and its state are one append-only table (`office_local_authority`, migration 19, STORE tier in the office's own catalog beside the identity writes) rather than a log plus a flag that could disagree with it; a row names the *fingerprint* — `sha256(secret_digest)`, one hash past the proof key — of the token in force when it happened, so a re-issued token does not match and the new join is closed again by construction rather than by somebody remembering to clear something. Reporting is a re-send of everything unacknowledged on every successful registration, after the mutual proof and never before it, so an office offline for a month reports on its first reconnect and one that never reconnects keeps the record locally with `reported_at` `0` and says so; the office id on an arriving report is taken from the token and never from the body. Detach removes the join token and nothing else, because §4 made everything else the office's own whether joined or not, and local accounts come back because §5 disabled rather than deleted them; it is an **office-side act only** — §2 gives the controller three verbs and says there is no fourth, and an office whose controller has been destroyed must still be able to leave, which a controller verb could not do — with revocation as the controller's own half. A detach is recorded and never reported, since reporting it needs the credential detaching gives up. Sessions do not survive either act, vouched or not, and that falls out rather than being decided: platform web sessions live only in a running process's memory and both commands require the server stopped. A controller-created account stays reachable by vouch and by nothing else through both acts — its password is 32 random bytes hashed and discarded unread, and break-glass additionally refuses to name one as its password-rotation target, checked against this office's own identity-write log. Proven live on scratch instances: a joined office refusing with the actionable message while a standalone one beside it logs in unchanged, the vouch door still opening that same office, break-glass with the controller killed, the record read by the office's own superadmin, the report arriving at the controller after it came back, a crossed report from another office refused, and detach leaving a byte-identical inventory of projects, data, files, and the 19-package blessed shelf. Recorded rather than invented, because the contract does not decide them: whether re-joining after a break-glass should re-close the door is answered by the fingerprint rule (it does) rather than by a sentence in §6; an operator who deletes the token file by hand instead of detaching bypasses the recorded act, which is host access behaving as host access; and there is no un-break-glass verb, since §6 names no way back and detaching or re-issuing the token both reach the same end. Owner mapping (§5's non-empty-office join condition) is still the one term of the treaty with no code. Security catch-up 2026-08-30, five adversarial reviews, each finding fixed with a regression test that fails without it. (1) **An office's token was a universal project-owner credential.** `require_project_api_capability` and its page sibling short-circuited on a role-switching “is this a valid cluster header” predicate that, on a controller, returned true for *any* active office's join token, with the owner taken from the URL — so any office could export or overwrite any user's project on the controller through `/api/internal/project-transfer/...` or any project route at all, which made §8's “revocable for one office alone” false and §2's three verbs a fiction. The predicate is now one-directional and named after its direction (`is_controller_call`): true only on an office, only for a header its own controller signed against that office's current token. Every route was decided individually — controller→office (internal runtime execute and webhook, internal project-transfer export and import, the MCP session-header proxy, project FS proxy reads, preview toggle/status/page/WS, hub-producer mode, webhook and WS trigger auth bypass, and both project-capability gates) now requires a controller call, which a controller answers `false` to always; office→controller (registration, heartbeat, break-glass reporting) keeps `require_registering_office`, which additionally refuses unless this instance is a controller, so neither half is reachable through the other's name. (2) **A stored hash was being used as a live MAC key.** Every controller→office proof — the registration answer, the internal-call header, and the vouch — was an `HMAC-SHA256` keyed by `sha256(secret)`, which is exactly what the controller stores and exactly what `GET /api/platform/cluster/join-tokens` and the mint response returned: verifier equalled forger, and a reviewer minted a vouch for an identity the office had never held, obtained a `superadmin` account, then forged the controller-call header and walked past the office's project auth with no cookie. The direction is now asymmetric. The controller holds an **Ed25519** private key at `platform/cluster-signing-key` (0600, STORE tier, load-or-create on first start, never regenerated on a read error because every issued token carries its public half); the office stores only the 32-byte public key, delivered inside its token. Ed25519 for the ordinary reasons — deterministic, fixed-size so a vouch stays a URL, fast enough to sign on every internal call, no parameters to misconfigure — via `ring`, which `jsonwebtoken` already pulls in, so the dependency is a line and not a crate. Redemption stays self-authenticating, which §3 and §6 require rather than permit: verifying a signature needs no network, exactly as verifying an HMAC needed none. The token moves to `zfjoin2:<office_id>:<controller_verify_key>:<secret>`, with `zfjoin2c` and `zfjoin2v` beside it; a `zfjoin1` value is refused **by name** with the re-mint named, which is §8's “old tokens stay parseable rather than merely invalid” promise being kept rather than described. One controller key does not flatten per-office revocability, because every signed message names that office's token **fingerprint** (`sha256(sha256(secret))`, one hash past the stored digest, derived by the controller from its record and by the office from its secret, never transmitted): rotating one office's token changes its fingerprint, so every proof minted under the old membership stops verifying there, cryptographically and with no list to update. The digest also stopped leaving storage: `secret_digest` is `skip_serializing`, so it is in neither the mint response nor the listing. (3) **A lost update un-revoked a token.** `verify_office_token` read the row, checked it, set `last_used_at`, and wrote the *whole row* back through a full-row upsert, with the read and the write taking the connection lock separately — and offices heartbeat every ten seconds, so a revoke or rotate committing in between was silently overwritten, putting `status` back to `active` or restoring a superseded digest and invalidating the token just issued. Recording a use is now one conditional statement, `UPDATE ... SET last_used_at = ? WHERE office_id = ? AND status = 'active' AND secret_digest = ?`, with zero affected rows treated as a verification failure (`CLUSTER_JOIN_TOKEN_STALE`) rather than a bookkeeping miss; `status` and `secret_digest` appear only in the `WHERE`. (4) **Windowed reads were being used as authoritative state.** `break_glass_in_force` fetched the newest 200 local-authority rows and scanned them, so an office past that many events silently re-locked its own front door with no rotation and no act by anybody — the exact lockout §6 exists to prevent; `unreported_break_glass` dropped its oldest unreported acts the same way; and the guard refusing to hand a local password to a controller-created account scanned only the newest 500 identity writes, so past that it would have handed one over. All three are now queries for the specific thing (`WHERE join_fingerprint = ? AND event = 'break-glass'`, `WHERE event = 'break-glass' AND reported_at = 0`, `WHERE owner = ? AND action = 'created'`). The paginated list APIs stay, for display; it is only the *decisions* that must not be windowed. (5) **A joined office could not restart unattended.** `missing_required_env` demanded `ZEBFLOW_CLUSTER_JOIN_TOKEN` unconditionally for an office and never consulted `platform/office-join-token`, so `zeb office` refused to start after a reboot even though the credential was on disk — which made `zeb help` and `interface.md` §5's “for its **first** join only” false and every restart a manual one. It now takes whether the data root already holds a token, checked before the root is opened; the mismatch refusal is unchanged and still names both offices. Proven live on scratch instances with scratch data roots and ports: an office joined and heartbeating on `zfjoin2`, its own token refused 401 on all six project and internal-transfer routes on its controller while the same token still heartbeats 200; the mint response and the listing carrying no digest; forged vouches and forged controller-call headers keyed by the controller's stored digest refused (`CLUSTER_VOUCH_MALFORMED` for the old scheme word, `CLUSTER_VOUCH_INVALID` for this release's exact construction) with the office's `office_identity_writes` empty and its account list unchanged; the honest vouch still opening the office once and 401 on replay; the office restarting with the variable unset and re-appearing `online`; a disagreeing variable and a `zfjoin1` token both refused by name; and a vouch minted after a rotation refused by an office still running the old token. **Deliberately not built, with the reason rather than the omission:** the office is *not* taught to verify plain revocation. A signed revocation statement would be verifiable, but the office can only learn it on a round trip it may never make, so it could never be the authority on membership; and a stored “revoked” that the office cannot un-learn without the controller reintroduces exactly the dependency §6 exists to remove. Rotation is the mechanism that already closes the gap and the office enforces it offline by fingerprint, so the operator's answer to “shut this office out now” is rotate, not revoke — and the residual for plain revocation is unchanged at one TTL. Still open, recorded rather than invented: the controller-to-office header is deterministic, so a captured one replays until that office's token is rotated (a nonce or timestamp on every internal call is the stronger form and is not built); §8's "controller's CA digest" is answered by the controller's Ed25519 verification key carried in the token rather than by a CA, since no certificate authority exists; revocation stops membership without stopping execution, which §4's kept-execution term implies but does not state; and three questions the contract does not decide, answered conservatively and recorded here rather than invented into it — whether a vouched identity that already exists locally may have its role changed by the controller (it may not, today), whether §9's co-signature should gate account *creation* specifically rather than administrator insertion generally, and whether an office should be able to refuse vouches entirely without detaching, which §4 lists as an accepted term with no opt-out |
| 14e | P0 | [Credential](./kinds/credential/README.md) | One stored credential: the secret-bearing value a project holds so a node can authenticate | Database record; the secret half is ciphertext | `ProjectCredential`, `project_credentials`, node-bundle credential type declarations | Review | Spec sealed 2026-08-29 from a four-researcher survey (`surveys/credential-storage-practice.md`, maintainer notes outside this repository): a field with no declared exposure is secret, declarations merge along the type's inheritance chain, an unresolvable type redacts entirely, and names are checked at registration only. Code catch-up 2026-08-31, the at-rest half, security-bearing: `secret_json TEXT` was plaintext in `sqlite.rs:68` and the tree held no encryption primitives at all. It now stores `"zfc1:<key_id>:base64url(nonce ‖ ciphertext ‖ tag)"`, the whole secret blob whatever a type declared, sealed and opened at the **storage boundary** (`SqliteDataAdapter::{put,get,list}_project_credential`) rather than in a service, so no writer can reach the column without passing the cipher and nothing above it changed — nodes read the same decrypted `serde_json::Value` they always did. `zfc1` is ChaCha20-Poly1305 (RFC 8439) from `ring`: the contract recommends XChaCha20-Poly1305 and states that "the named cipher is a recommendation", and the *reason* it gives — constant-time in pure software, so it does not need AES hardware a Raspberry Pi may not have — is a property of the ChaCha core and not of the nonce extension, while the reason for the 192-bit nonce is met by arithmetic instead (random 96-bit nonces collide with probability about `n²/2⁹⁷`; a credential rewritten every second for a decade is `n ≈ 2²⁸`, so `2⁻⁴¹`, and rotation resets `n`). `ring` because `controller_key.rs` already chose it for the sibling key on the stated ground that it "adds a line and not a crate", where `chacha20poly1305` would have added `poly1305`, `aead`, and `universal-hash`. AES-256-GCM was rejected on the Pi argument alone. The tag is what makes the trade reversible: a later `zfc2` can be XChaCha while every `zfc1` byte stays readable. **Key versioning ships in the first release**, as the contract requires because "rotation is the one feature nobody has been able to add afterwards": one instance key at `<data-root>/platform/credential-key` (0600, load-or-create on first boot, `ZEBFLOW_CREDENTIAL_KEY` overriding it, never a compiled default, never regenerated on a read error — `controller_key.rs`'s house style) wraps versioned data keys held as `zfw1:` rows in `credential_keys` (migration 20), and every ciphertext names the generation that made it. The wrapped keys live in the catalog deliberately, and it is not the "key stored in the database beside the ciphertext" the contract forbids — a `zfw1:` row is inert without the file — because **the keyring is what tells first boot apart from a lost key**: a key file that also held the keyring would, when absent, be indistinguishable from a machine that has never started, so a deleted key would be answered by generating a new one and treating every credential as unreadable, which is Jenkins' silent failure exactly. Rows present and no key is `PLATFORM_CREDENTIAL_KEY_MISSING` and a refusal to start; rows present and the wrong key is `PLATFORM_CREDENTIAL_KEY_UNUSABLE`, loud because the AEAD authenticates; variable disagreeing with file is `PLATFORM_CREDENTIAL_KEY_MISMATCH` and overwrites neither, the rule `interface.md` §3a already names this contract as the source of. The refusal deliberately does not consult the credential table — an instance holding nothing still refuses, which is while the operator can still go and find the file. It runs inside `SqliteDataAdapter::new`, so every server mode (`zeb/react`, `zeb controller`, `zeb office`, `zeb run`) and every offline command reaches it through one function; the office-hosted hub store opens by db path, holds no keyring, and refuses credential operations it never performs. Rotate and rekey are **API and not CLI** because the contract says "two operations, both online" and `interface.md` §3's Group 3 / `admin` noun is for offline maintenance "run when the server will not start" — `POST /api/platform/credentials/{rotate,rekey,reencrypt}` and `GET .../keyring`, superadmin, the same conclusion join-token minting reached. Rotate inserts a generation and re-encrypts nothing; rekey re-wraps the data keys under a new instance key in three steps (file gains the new key beside the old → rows move in one transaction → file drops the old), so an interruption between any two leaves a readable state, and it is refused when the key comes from the variable because the instance does not own the file; `reencrypt` is the separate sweep the contract names, and is also what converts pre-release plaintext rows. Recorded rather than invented, because the contract does not decide them: pre-release rows are **accepted on read and rewritten as ciphertext on the next change**, the `ZebFsAcl` sibling's rule (row 9) rather than the regenerate-or-rot default, because the contract's own "a record whose secret half is not ciphertext" rejection cannot apply to a value that cannot be regenerated — the sweep converts the ones nothing ever writes again; and the AEAD's associated data binds the format word and the key id and **not** the row's `owner/project/credential_id`, because row-binding defends an attacker with write access to the live catalog, whom the contract explicitly places outside this feature's reach, at the price of `transfer_project_owner`, which rewrites `owner` with a plain `UPDATE` and would silently strand every credential in the project it moved. Still owed, and now the whole gap: the **declaration half** — `exposure` markers on credential type fields, inheritance-aware merging, whole-record redaction for an unresolvable type, and the registration-time refusal of a secret-looking field declared public — has no code at all; `CredentialFieldDef.field_type: "password"` is a UI hint that gates nothing, which is precisely the "declaration governs rendering rather than storage" failure the survey names in Windmill and Airflow. It costs display safety only, never storage safety, because the whole blob is encrypted regardless. Closed 2026-08-31: `instance-directory.md` now draws all three key files (`platform/credential-key`, `cluster-signing-key`, `office-join-token`) with their modes and, for the credential key, what losing it costs. Still open from the spec: narrowing which nodes may decrypt a type, audit on decryption, pluggable external key managers, masking limits |
| 14f | P1 | [Office topology](./kinds/office-topology/README.md) | Which offices exist, what role each holds, where services are placed, which runtime nodes are registered | `PlatformOffice`, `PlatformServiceInstance`, `PlatformOfficeNode`, `management.yaml` | `src/platform/services/cluster/`, `src/provision/k8s.rs` | Review | Spec sealed 2026-08-30: `base_url` is load-bearing because it is where the public reaches that office's projects; placement is the operator's and a project never expresses where it runs, deliberately, because project-declared provisioning is a second system with its own matcher, capacity model, and influence surface. Corrected a factual error in the earlier stub: `.zebflow.cluster.lock` is a mutex, not the on-disk topology. Rejection catch-up 2026-08-31: "an office nothing can reach is not an office" was a sentence and not a check — `ClusterJoinTokenMintRequest.base_url` was `#[serde(default)]` and `mint` wrote it unread, so an operator could mint a token for an office with no address at all and get a planned row nothing could ever place work on. Every writer of a `PlatformOffice` row now goes through `normalize_office_base_url`: minting, registration, and heartbeat all refuse an empty address, and the refusal happens before the office record is written so nothing half-exists. The check is presence, not shape — the contract names the missing address as the rejection and says nothing about scheme, and the operator's `ZEBFLOW_ADVERTISE_URL` is theirs to spell. Still open: applying a placement change without stranding work, a `status` vocabulary and staleness rule, and `management.yaml` has no schema or version field. Noted 2026-08-30: minting a join token (row 14d) creates the office record before anything registers, with `status` `planned` — a fourth free-text word beside `online`, and the first one that means "exists, has never been reached", which is the case the missing vocabulary most needs |
| 15 | P1 | Public and cluster protocols | HTTP API, webhook, MCP, controller-worker, and service messages | Mostly route-based or unversioned | Platform routes, MCP, cluster services | Critical | Group protocol families, define compatibility windows, and version durable wire envelopes. Closed 2026-08-31: `POST .../pipelines/definition` skipped its `pipelines.write` check whenever no session cookie was present, so being logged out granted more than being logged in without the capability and any reachable caller could create or overwrite an executable pipeline and attach a webhook trigger to it; the check is now unconditional like every other project write. A route-table sweep found no other authorization guarded by the presence of a session. MCP `git_command` and the DSL `git` verb allowlisted only the subcommand and passed the remaining arguments to `git` verbatim; `current_dir(repo_dir)` bounds relative resolution and nothing else, so `git diff --no-index /etc/passwd /dev/null` returned any readable file in the tool result and `--output=<abs>` wrote one. Closed 2026-08-31 with a per-subcommand argument policy (allowlisted flags, `--flag=value` only for value-bearing flags, and no positional argument that is absolute or contains a `..` path segment — a `main..HEAD` revision range still passes). Still open on this row: `POST .../pipelines/dsl` is gated on `pipelines.execute` although its `register`/`patch`/`delete` verbs are writes — no shipped role preset holds execute without write, so it is a latent gap rather than a live one, but the per-verb gate is owed |
| 16 | P2 | [Invocation and temporary state](./kinds/invocation-record/README.md) | Invocation records, traces, node payloads, caches, and temporary files; **error groups** (one row per distinct failure, count and occurrence ring) and the **request id** (`run_id` as `X-Request-Id`), decided and implemented for webhook runs 2026-09-17; other triggers owed the group | No common version; database record, no envelope | Pipeline runtime and invocation services | Review | Spec sealed 2026-08-29: bounded by newest-N (default 20) and optional maximum age, per pipeline overriding per project; secrets are redacted by the node's own declaration first, with name matching kept as a fallback. Payload secrets enforced 2026-08-31. The webhook trigger's declaration mechanism was dead in production: `collect_trace_private_tokens` read top-level keys only, while the ingress always nests the request body under `body`, so a posted login form wrote its plaintext password into `trace.input` and onto disk on every run. The collector now walks the whole payload to a bounded depth, and the engine additionally blanks any value under a secret-sounding key so a cron, WS, or script-built run is covered without a node declaring anything; `__zf_private_redact_except_paths` still overrides both. Two adjacent leaks closed with it: the marker carrying the tokens is itself a list of the plaintext secrets, and it was riding out in the HTTP response body and — once `n.web.response` nested the payload under `__zf_response.body`, one level below where the sanitizer looked — into the stored trace verbatim. Markers are now swept recursively out of both. Re-decided 2026-09-01, and the owed item withdrawn. The spec had asked for a fourth mechanism — node-declared secret fields — on the ground that a connection URL is written to the run history in full. It is, and it stays that way: a secret belongs in a credential, where only the `credential_id` reaches the config, and `http_request`'s `secure_request` kind supplies the entire request so no node forces a secret to be typed inline. Typing one into a free-text field is bypassing the mechanism, and the proposed flag would not have caught it either, because no author would mark `url` or an `n.script` `source` secret — they are ordinarily readable and are what makes a trace worth opening. What the audit did find was a real gap in the fallback: `accesstoken` was matched and a plain `token` was not, so a third-party node holding a secret in a plainly-named field fell through; `token`, `apitoken`, `authtoken`, `sessiontoken`, and `bearertoken` were added. Still open: no maximum trace byte size though `trace_bytes` is already measured, deletion undefined, no rule on which roles may read a trace, high-throughput behaviour undefined |
| 17 | P1 | [Library definition](./kinds/rwe-library-manifest/README.md) | Installed library manifests, versions, integrity, and project enablement | `zebflow.com/v1` `RweLibraryManifest` | `src/contracts/kinds/rwe_library_manifest.rs`, `src/platform/services/library.rs`, library catalog files | Review | The validator enforces the settled spec — `offline`/`hub` sources only, required sha256 integrity, non-zero sizes, descending entries — the blessed manifests carry real digests kept honest by recompute tests, and the golden fixture round-trips; the first-party Zeb React engine is now an internal embedded runtime and no longer seeds a manifest-less installable package |
| 18 | P1 | [Map publish manifest](./kinds/map-publish-manifest/README.md) | Published layer identity, source, generated artifacts, styles, and caches | `zebflow.com/v1` `MapPublishManifest`; generated artifacts are private formats | project MapServer service, `src/mapserver/publish/` | Review | Spec sealed 2026-08-29: `allowed_properties` closed by default — empty means geometry only, no wildcard, the UI's select-all writes real column names. Code caught up 2026-08-31: one `prune_feature_properties`/`property_is_public` pair in `src/mapserver/resolve/mod.rs` now governs every serving path — GeoJSON, chunked artifact, GeoParquet via DataFusion, the direct GeoParquet→MVT encoder, and the GeoJSON→MVT fallback — so an empty list yields geometry alone everywhere, and the list is now part of a tile's cache key so narrowing it cannot be answered from a tile built before the narrowing; the public `/stats` endpoint reports only exposed columns, and the whole-source view the publish UI needs moved to the authenticated `.../layers/{layer_id}/stats`, which also reports `allowed_properties` so the operator can see what is exposed. `path` is stored without a leading slash by both publishers and normalised on read (lazy migration, no read failure for older records). `LayerRecord` in `ms/crud.rs` (then `mapserver_crud.rs`) is gone: the node uses the contract's `MapserverLayerRecord` and the shared registry reader/writer in `src/mapserver/publish/registry.rs`, which also ends a second, undocumented drift — the node read and wrote a bare JSON array where the web side reads and writes the contract envelope, so neither publisher's layers could be read by the other. The drifted `column_stats_path` was dead (no reader) and was removed; the sidecar file is still written and still reported in the node's `optimization_info`. Second catch-up 2026-09-01, security-bearing: the contract's "a `source_path` that escapes the project" rejection was a sentence and not a check. Both writers were safe — the HTTP publisher requires `mapserver/` after `normalize_object_path`, and the node's `zebfs.head` refuses a `ParentDir` component — but the registry is `files/mapserver/{instance}.layers.json`, an object a project member may upload over and a file a project bundle carries, so a record can arrive already written, and the serving path joins `source_path` onto `files_dir` with `trim_start_matches('/')`, which does not resolve `..`. `MapPublishManifestContract::validate` now refuses an escaping `source_path`, and `read_layers` repeats the check for the legacy bare array, which never reaches the validator. Still open: artifact rebuild is untested, deleted-source behaviour undefined, `style` has no schema; the contract now states that a `path` carrying a leading slash is normalised on read rather than refused, because `validate` runs on read and refusing there would make pre-fix records unreadable |

## Verified Audit, 2026-08-14

The source audit and an independent read-only review reached the same release
assessment:

- Zebflow is ready for demonstrations and controlled single-node development.
- A pinned single-node production deployment is possible with external backups,
  trusted inputs, and conservative payload sizes.
- Zebflow is not ready to declare a stable v1 storage and transfer contract.

The full library suite passed: 420 default tests and three tests ignored by
default, run separately. This proves the currently tested behavior. Focused
failure tests now cover atomic-write interruption and forward-version rejection
for the contracts listed in the implementation record below. It does not yet
prove multi-file rollback, full package rollback, or every tamper boundary.

The strongest verified mechanisms are the central strict contract decoder,
transactional platform SQLite migrations, path-confined atomic ZebFS object
writes, FileRef size and digest checks, graph and node-definition validation,
remote Hub artifact hash checks, and staged map artifact publishing.

The highest remaining risks are multi-file operations without rollback,
destructive project import and runtime materialization, non-atomic node and Hub
installation, temporary FileRef cleanup, poisoned Sekejap locks, and repeated
cloning of large JSON node payloads. Other authoritative document formats still
need to adopt the shared strict reader and durable writer as their rows are
reviewed.

## Implementation Record, 2026-08-14

The contract kernel is implemented in `src/contracts/`. Generic atomic
byte replacement remains in `src/infra/io/durable.rs`:

- Same-directory temporary writes, file sync, atomic replacement, and parent
  directory sync on Unix.
- One closed kind registry and one `zebflow.com/v1` envelope.
- Strict root validation before typed deserialization or writing.
- Missing files are distinct from malformed, unreadable, and unsupported files.
- Injected partial-write tests prove that a failed replacement preserves the
  previous valid file and removes the temporary file.

The foundation currently protects these paths:

- `zebflow.yaml` project configuration.
- The existing RWE-only `zeb.lock`; its general RWE and node contract remains
  under review and must not be called frozen yet.
- ZebFS local objects and the ACL manifest.
- Pipeline source and active runtime snapshots.
- Installed node files and Hub artifact files at the individual-file boundary.

Canonical strict readers now cover project configuration, dependency locks,
ACL manifests, pipeline graphs, node definitions and bundles, database schemas,
project and project placements, Hub packages, library manifests, and map publish
manifests. Hub artifacts and FileRefs verify stored digests before use.

This record does not claim whole-operation atomicity. Pipeline source plus
metadata, node bundle installation, Hub installation, project transfer, and
runtime materialization still span multiple durable objects and need staging,
commit, and rollback mechanisms.

## Freeze Checklist

Every row must pass all applicable checks before its state changes to
**Candidate** or **Frozen**.

- [ ] The contract has one permanent identity.
- [ ] Its durable or transmitted root contains an explicit version.
- [ ] The owner module and storage or transmission locations are recorded.
- [ ] One canonical writer emits the current version.
- [ ] Readers list the exact versions they support.
- [ ] A central validator runs before storage, activation, installation, or
      execution.
- [ ] Unknown and newer versions fail with a stable, useful error.
- [ ] Unknown-field and missing-field behavior is explicit.
- [ ] Defaults are deterministic and do not change stored meaning silently.
- [ ] Size, integrity, trust, and access rules are explicit where applicable.
- [ ] Migration is atomic or has a written recovery procedure.
- [ ] Failed migration leaves the previous valid state usable.
- [ ] Golden files cover the current version and every readable older version.
- [ ] Negative tests cover malformed, unsupported, truncated, and tampered data.
- [ ] The versioning effect of future field changes is documented.
- [ ] Usage and developer documentation link to the same machine-readable
      definition.

## Review Record

The one-by-one review of each row must answer these questions:

1. What exact object or behavior does this contract protect?
2. Which parts are durable, transmitted, generated, cached, or temporary?
3. What is the canonical identity and envelope?
4. Who writes it, who reads it, and where does it live?
5. Which existing shapes must be removed before the first stable release?
6. Which fields are required, optional, computed, or forbidden?
7. How are large values, files, credentials, and untrusted input handled?
8. What changes are patch, minor, or major changes?
9. How does migration work, and what happens when it fails?
10. Which fixtures and tests prove the promise?

Each completed review updates this matrix and adds a focused contract document
or machine-readable schema. The matrix remains the summary; exact field rules
belong to the contract that owns them.

## Reference Practices

Zebflow uses Kubernetes as the primary reference for independently versioned
API families, validation, conversion, storage versions, and safe removal. It
uses Git for immutable identity and repository safety, OCI for typed bundles and
digest verification, Protocol Buffers for field compatibility discipline, S3
for object and access behavior, and PostgreSQL for transactional migration and
recovery.

These are design references, not formats to copy blindly. Zebflow keeps its own
small contracts and adopts the safety rules that match each responsibility.