Files
tech-log-frontend/docs/superpowers/specs/2026-08-02-provider-raw-guardian-design.md
T

13 KiB

Provider Evidence Guardian Transaction Design

Goal

Make one guardian process own the provider evidence filesystem transaction from raw creation through sealed publication. A supervisor or provider hard death must leave neither canonical raw evidence nor a guardian-owned sealed temp/final inode, and the same workspace must be immediately retryable. Only the complete authenticated publish -> PUBLISHED -> commit -> EOF sequence preserves the canonical sealed artifact.

Chosen Ownership Boundary

The guardian owns filesystem identity and publication. The supervisor retains archive, trust, schema, signature, and evidence validation. This avoids two unsafe alternatives:

  • Keeping raw-only guardianship would leave the sealed rename-to-supervisor-death cleanup gap.
  • Moving evidence validation into the guardian would duplicate security policy and make the helper unnecessarily privileged and complex.

The client opens the canonical provider-evidence/untrusted and provider-evidence directories with O_DIRECTORY|O_NOFOLLOW before spawning the guardian. Those identity-pinned directory descriptors are inherited as fd 3 and fd 4; they are never encoded in argv or the environment. The canonical raw and final leaves are fixed by provider kind. Before spawn, the client exclusively creates a nonce-private raw staging inode and the nonce-private sealed temp inode, records both identities, and inherits their open descriptors as fd 5 and fd 6. The guardian validates each inherited descriptor against its descriptor-relative pathname, then publishes raw staging to the fixed raw leaf with a no-replace hard link. Startup recovery authority is therefore an inode identity acquired before spawn, never an identity discovered later from an expected pathname. Both processes perform transaction I/O through /proc/self/fd/<fd>/<leaf> so pathname substitution cannot redirect creation or recovery into another directory. No raw path, sealed path, identity, provider command, or credential is an argv value. The initial request contains only the version, kind, random control nonce, and absolute deadline.

Transaction Invariants

  1. Before a complete valid guard frame, the guardian has not published a canonical filesystem object. The client may have allocated only zero-byte, mode 0600, nonce-private raw staging and sealed temp inodes whose identities it already holds. EOF with no frame or a partial frame removes both allocations.
  2. Before spawning, the client validates that both pinned descriptors name the expected canonical directories; computes fixed raw/final leaves and nonce-private raw-staging/sealed-temp leaves; and creates the private leaves with O_CREAT|O_EXCL|O_NOFOLLOW, mode 0600, size zero, and link count one. It retains both handles and inherits them as fd 5/fd 6 in addition to directory fd 3/fd 4.
  3. At bootstrap, the guardian fstats fd 5/fd 6, reads only their /proc/self/fd/5|6 link targets, and accepts each basename only when it is a direct child of the canonical fd 3/fd 4 directory and matches the exact provider-kind/32-lowercase-hex private-leaf grammar. It then requires descriptor-relative lstat of that basename to match the already-fstat fd identity, type, mode, size, and link count. This binds a deletion alias to an inherited identity; it never promotes a pathname-discovered identity to ownership. The two basenames must encode the same kind and nonce prefix.
  4. After guard validation, the guardian verifies that the received kind/nonce derives those exact bootstrapped private leaves. It verifies fd 5/fd 6 remain regular zero-byte single-link 0600 files and exactly match the derived private pathnames. It uses link(raw staging, canonical raw) without replacement, verifies both names have the inherited raw identity and link count two, unlinks the private raw name, fsyncs the raw directory, and verifies the canonical raw link count is one. READY is emitted only after this authority transfer succeeds.
  5. READY is authenticated by the request nonce and returns raw dev/inode plus sealed temp leaf/dev/inode. The supervisor starts the provider only after it validates this exact bounded response with constant-time nonce equality.
  6. The supervisor writes only schema-validated sealed bytes to the temp inode. It opens with O_NOFOLLOW, checks dev/inode before and after writing, applies mode 0400, writes the complete bounded bytes, fsyncs, and closes.
  7. Publish metadata contains the nonce, sealed dev/inode, byte length, and SHA-256. The guardian checks the held descriptor and temp pathname identity, regular-file type, link count, exact mode/size/hash, and canonical final-path absence.
  8. Publication uses atomic no-replace link(temp, final), then unlinks temp and fsyncs the parent directory. If death occurs between link and unlink, both names refer to the same pinned inode and both are cleanup candidates.
  9. PUBLISHED is authenticated and is emitted only after final pathname identity and directory durability are verified.
  10. Commit is legal only after PUBLISHED. It removes the pinned raw inode and enters commitPending; it does not exit. EOF with no pending bytes is the sole success terminal and preserves only the sealed final inode.
  11. Any data after commit, including a separate later chunk, is a protocol error. EOF/abort/deadline/protocol failure before the success terminal cleans raw, temp, and final only when each path still names the guardian-owned identity.
  12. If the guardian dies before READY is accepted, the client attempts cleanup of raw staging, canonical raw, sealed temp, and sealed final aliases using only the two identities recorded before spawn. A current pathname is never opened and promoted to an owned identity. A competing canary or same-kind transaction therefore survives every startup failure.
  13. Cleanup attempts every owned target and reports cleanup failures together with the primary failure using AggregateError at the supervisor boundary. Client fd 3-fd 6 handles and guardian fd 5/fd 6 duplicates are closed on every success and failure branch; close errors join the same aggregate rather than skipping remaining cleanup.

Client-side exclusive private allocation is the startup ownership token. The guardian accepts that token only after inherited-fd, descriptor-relative pathname, type, mode, size, and link-count checks. Every cleanup identity is recorded at allocation or authenticated READY; pathname discovery never creates authority. Creation, validation, link, unlink, chmod, fstat, close, publish, sync, and cleanup failures all fail closed.

Bounded Authenticated Protocol

Every control or acknowledgement message is a four-byte big-endian length plus canonical JSON with an exact ordered field set, strict UTF-8, no NUL, and a total payload bound. Unknown, duplicate, reordered, oversized, truncated, or trailing fields are rejected.

The state sequence is:

guard -> READY(raw identity, sealed temp identity)
      -> publish(size, sha256, sealed identity)
      -> PUBLISHED(sealed identity)
      -> commit
      -> EOF success

All messages carry the same 32-byte random nonce. READY and PUBLISHED are validated with timingSafeEqual; publish and commit are authenticated the same way. Commit merely changes state, so a byte delivered in a later chunk before EOF remains observable and causes fail-closed cleanup.

The maximum initial lease is the provider wall timeout plus a fixed ten-minute post-processing allowance. The provider timeout remains bounded at 30 minutes, so the guardian maximum is 40 minutes. Near-provider-timeout tests must show that valid publication still has post-processing time, while an expired lease cleans all owned objects.

Supervisor and Scope Exit Ownership

The lease exposes raw/temp/final identities, publish(bytes), commit(), abort(), and a non-rejecting premature-exit promise. The client knows all possible leaves and both startup identities before spawn and retains its pinned directory and private-file handles until the lease terminates. Before READY it cleans only aliases that still match those recorded identities. After READY it checks the guardian response against the same identities and fallback-cleans raw, temp, and final by identity if the guardian dies.

Provider waiting owns an explicit scopeActive latch. A guardian exit starts whole-scope kill and collection only while that latch is true. Once the scope completion path has collected the unit, the callback records a lifecycle error but cannot start an unawaited kill. Publication and terminal commit observe the guardian exit through their normal awaited failure path and clean sealed state.

Provider stdout and stderr are untrusted secret-bearing byte streams. The supervisor counts and bounds them for resource enforcement but never forwards their raw bytes into supervisor/CI stdout or stderr, on either success or failure. Functional provider assertions use signed evidence or a non-log side channel. Sealing/output I/O is allowed to settle; the design does not claim OS-level cancellation. GITHUB_OUTPUT is a runner-owned regular file. After output append succeeds, commit makes the guardian remove raw and EOF completes the transaction.

Guardian diagnostics are best-effort only. A closed stderr or control descriptor must not turn a fail-closed branch into a resolved operation or exit zero: diagnostic and fd-close failures are absorbed after cleanup, and a nonzero exit or requested fatal signal is issued unconditionally.

Failure and Recovery

  • No/partial guard EOF: no canonical raw or sealed object is published. The guardian removes both nonce-private allocations through aliases that bootstrap already bound to inherited fd identities, without needing kind/nonce from a complete control frame.
  • A competing canonical raw canary or another same-kind attempt causes no-replace link failure. The loser removes only its private identities and never removes the winner or canary.
  • Guardian death after linking raw but before READY: the client uses its pre-recorded raw identity to clean both private and canonical aliases and its pre-recorded sealed identity for temp/final aliases, then retries the same workspace immediately.
  • Parent death after creation but before READY: stdout/control pipe failure or EOF makes the still-running guardian clean both owned objects.
  • Guardian death after READY: the supervisor knows raw and sealed identities and cleans raw, temp, and final fallbacks.
  • Supervisor death after PUBLISHED: guardian EOF cleans raw and the published final inode, including the link/unlink intermediate state.
  • Publish or commit race: serialized guardian state completes the current file operation, then applies EOF/protocol failure cleanup; success requires clean EOF after commitPending.
  • Cleanup failure: remaining targets are still attempted and every error is preserved; PASS is impossible.

There is one bounded crash window before spawn: if the client itself is killed after private allocation but before the guardian is created, zero-byte 0600 nonce-private leaves can remain. They contain no provider or credential bytes and cannot occupy the fixed canonical raw/final names, so they do not block an immediate same-kind retry. Automatic pathname sweeping is intentionally omitted because an unproven stale pathname is not deletion authority.

After each observable managed-process failure, tests require canonical raw, private staging/temp, canonical final, guardian, and provider cgroup residual counts to be zero before retrying the same workspace successfully. The documented pre-spawn client hard-death window is the sole residual exception.

Verification

Real-process tests cover no/partial frames, a competing raw canary, same-kind concurrency, guardian SIGKILL after raw link but before READY followed by same-workspace retry, parent death around READY, valid READY identities, EOF/deadline cleanup, publish/PUBLISHED, post-scope guardian death, supervisor death after publication, commit trailing bytes in a later chunk, closed-stderr fail-closed termination, near-timeout publication, and no residual guardian/files. A provider that successfully prints a supplied credential is verified not to expose it through supervisor stdout/stderr. Live fixtures also specify active-scope guardian kill, detached-child external marker/raw append suppression, cgroup collection, and same-workspace retry. Live systemd/bwrap execution remains explicitly unverified when the approval limit prevents running it.

The external-canary regression waits for a test guardian spawn marker before creating the fixed raw file, proving that the initial absence check has already completed. The fixed raw bytes and dev/inode must remain unchanged after startup rejection. The pre-READY link regression watches only the fixed raw basename, kills the exact direct child on that link event, and requires identity-bound cleanup plus an immediate same-workspace retry.