Data inventory: what the hosted service holds

Every field the hosted Keep Shipping service receives, why we hold it, how long we keep it, and how it is deleted. It covers three modules — the early-access waitlist, which is written and deployable but not deployed, and web approvals and the run log, which are further from that — and a fourth group of items the hosting edge holds rather than we do: rate limits, captcha, Cloudflare's own logs. The fourth hosted surface in ARCHITECTURE.md, the blocks index, is deliberately out of scope here; see the Gaps section.

Four things this document does not do. It does not wire cratefield-module-privacy; that crate is not a dependency of this repository, and the declarations under ventures/keepshipping/privacy/ are the contract it will consume, committed ahead of the code. It does not schedule the retention sweep — no cron is wired, so nothing is swept today. It does not claim the service is running: wrangler.toml binds a placeholder database_id, so no waitlist data has ever been collected. And it does not describe the site's own privacy notice, which is still pending (website#4); this document is the input to that notice, not a substitute.

How to read this

The runner is local-first: plan bytes, secrets and source never leave the machine that runs them. What crosses the runner → hosted boundary is a closed allowlist, ratified in ADR 0005; this document covers the other side of that boundary, what the hosted service ends up holding.

The governance rule is the ADR's: egress is a closed allowlist, and adding a field means amending the ADR. The mirror of that rule governs this file — adding a field to a hosted module means adding a row here and a key to the module's declaration in ventures/keepshipping/privacy/, and both are reviewed together. A field that reaches a hosted module without a row here is a defect, not an oversight.

The Waitlist retention cells say "no sweep is scheduled" rather than a window wherever the window is not in force. A retention window that no code applies is not a retention policy. The other modules say what their own store does with the row: in-process for approvals, on disk for the run log, and whatever the hosting edge does for the rest.

The inventory

ModuleFieldWhat it isWhy we hold itRetentionDeletion path
WaitlistidULID, primary keyIdentifies the entry without exposing who it is; confirm tokens bind to itno sweep is scheduledWith the row: anonymise, or delete by id
WaitlistemailThe address given at signup, as typedThe only address we mailno sweep is scheduledAnonymised to NULL
Waitlistemail_normalizedLowercased form of the same address, for the per-product uniqueness constraintUniqueness; a second signup cannot claim a place twiceno sweep is scheduledAnonymised to NULL
WaitlistproductThe product code the entry is for (keepshipping)A queue is per productno sweep is scheduledKept on anonymise
Waitliststatuspending or confirmedDistinguishes a captured address from one that proved itno sweep is scheduledKept on anonymise
WaitlistpositionDense per-product join order, never recomputedThe number status reports to the person waitingno sweep is scheduledKept on anonymise — deleting a confirmed row would change a count others can see
Waitlistreferral_codeUnique code the person sharesAttribution for the referral creditno sweep is scheduledKept on anonymise
Waitlistreferred_byThe referral_code of whoever invited themCredits the inviterno sweep is scheduledKept on anonymise — deleting would orphan every referral pointing at it
WaitlistreferralsCount of referrals already credited to this rowThe score the waitlist page ranks byno sweep is scheduledKept on anonymise — it is a credit already granted to somebody else
WaitlistanswersOpaque text. Its field list is unspecified: no answers form is defined yet, and every field will be named here before the form goes live (#146)Answers questions the form asked; none are asked todayno sweep is scheduledAnonymised to NULL
Waitlistcreated_atText timestamp of the signupOrdering the queueno sweep is scheduledKept on anonymise
Waitlistconfirmed_atText timestamp of the double opt-in, if it happenedTells a captured address from a proven oneno sweep is scheduledKept on anonymise
WaitlistgenerationInteger, starts at 1; confirm tokens bind to (id, generation)Makes a replayed confirm token inertno sweep is scheduledKept on anonymise
ApprovalsseqMonotonic ticket counterTicket ids are ha-1, ha-2, …; not a credentialProcess lifetimeProcess exit
Approvalsrequest.runRun id the request belongs toBinds a ticket to one runProcess lifetimeProcess exit
Approvalsrequest.stepStep name awaiting a decisionSays what is being approvedProcess lifetimeProcess exit
Approvalsrequest.summaryFree prose, rendered for a person to read before decidingAn approver cannot judge a diff from a hashProcess lifetimeProcess exit
Approvalsrequest.artifactsThe (kind, digest) pairs — the digest is the identity an approval binds to. The plan file is itself just an artifact of kind PlanFileLets the approval be bound to exact bytes without holding themProcess lifetimeProcess exit
ApprovalsMasked PlanSummaryPer ADR 0005: add/change/destroy counts and the risky and others entries, each with kind, address, reason and attributes path, before, after, forces_replacement, masked once by summarize, bounded by HOSTED_BODY_LIMITAn approver judges a change, not a hash. This is a disclosure: resource addresses and non-secret attribute values reach the hosted side, which ADR 0005 says plainly is realIn flight to the review page; not written to the storeNothing stored; gone when the request ends
ApprovalsActionClassThe gated step's class — its kind and, when it has one, envSays what sort of action is being authorisedIn flight to the review page; not written to the storeNothing stored; gone when the request ends
Approvalsfull_plan_urlOptional URL to the full plan, when setLets a person see the unmasked plan without the service holding itIn flight to the review page; not written to the storeNothing stored; gone when the request ends
ApprovalsDecision SignatureADR 0005's returning decision carries approved, a by handle with the verified identity, a timestamp, the plan digest it binds, and a Signature { bytes, identity }The service says who decided, never who mayIn flight to the review page; not written to the storeNothing stored; gone when the request ends
Approvalsrequest.fromThe team that askedAn approval is scoped to a teamProcess lifetimeProcess exit
Approvalsrequest.reasonFree prose, the proposer's caseWhy now, for the approverProcess lifetimeProcess exit
Approvalsrequest.proposed_byIdentity: a handle plus Human or Service. No email, no display name, no account idNames who askedProcess lifetimeProcess exit
Approvalsverdict.approverIdentity of the person who answeredNames who said yesProcess lifetimeProcess exit
Approvalsverdict.atSystemTime of the verdictOrders answersProcess lifetimeProcess exit
Approvalsverdict.outcomeOutcome::Approved or Outcome::RefusedThe decision itself. (Granted/Denied are the run-log wire names, a different vocabulary)Process lifetimeProcess exit
Approvalsverdict.commentFree text the approver wrote. The one field here most likely to contain anything personalWhy somebody approved or refusedProcess lifetimeProcess exit
Approvalsverdict.bound_artifactsThe (kind, digest) pairs the verdict was bound toA verdict answers for specific bytes, not for a step nameProcess lifetimeProcess exit
Approvalschannelweb or slackWhich front end the answer arrived throughProcess lifetimeProcess exit
ApprovalsGitHubSession.loginThe GitHub login carried by the session. Request-scoped: the submit handler converts it to Identity::human(...) and the session is dropped. It is never put in the ticket storeA web answer needs a verified humanHeld for the request only; never storedProcess exit, or the request ends
ApprovalsSlack team_id, user_idSlack's own identifiers. Request-scoped: resolved to a handle at submit time, and only the handle is keptResolves a Slack answer to a personHeld for the request only; only the handle is storedProcess exit
Run logrun.startedfile_hash, trigger, actor handle, is_agentWho ran itLocal only; nothing is uploadedDelete the run file
Run logstep.startedStep name onlyProgressLocal onlyDelete the run file
Run logstep.finishedStep name, ok, output names, duration, redacted error textProgress and failureLocal onlyDelete the run file
Run logstep.rolled-backStep name, policy, status, reverted_to, reason textA failed deploy was undone and what it went back toLocal onlyDelete the run file
Run logapproval.requested, approval.expiredStep, channel, artifact hashesShows a run parked and releasedLocal onlyDelete the run file
Run logapproval.grantedApprover handle, channel, artifact hashesThe claim that a person looked at thisLocal onlyDelete the run file
Run logapproval.deniedApprover handle plus reason, the approver's own words or the engine's one lineThe refusal and its groundsLocal onlyDelete the run file
Run logapproval.abortedproposer handle, channel, artifact hashesDistinguishes "we stopped asking" from "we were told no"Local onlyDelete the run file
Run logapproval.rejecteddetail — which rule held, no approver to blameRecords that an answer was thrown awayLocal onlyDelete the run file
Run logdecide.sentRedacted plan state and questions shown to a modelkeepshipping decide --explain shows exactly what leftLocal onlyDelete the run file
Run logdecide.answeredModel answer, confidence, and question_hash / state_hash — sha256 of what was sent, so a decision can be matched to its inputs without the log holding eitherCalibrationLocal onlyDelete the run file
Run logcredentials.resolvedCloud, role, and how credentials were gotShows the shape of the assumption; the token is never hereLocal onlyDelete the run file
Run logdecision.overriddenby handle and reason free text — a human disagreeing with a recorded decisionThe override and its reasonLocal onlyDelete the run file
Run logattestation.attachedImage digest and pushed attestation envelope digestProvenanceLocal onlyDelete the run file
Run logpolicy.refusedAn optional step name (None before any step ran) and the rule that held. Carries no digest and no resource addressThe run stopped and which rule stopped itLocal onlyDelete the run file
Run logplan.stalePlanStale { step, cause, approved, replanned, differs }: the step that was applying, the cause (state when the tool found state moved, drift when a pre-apply recheck found the change set differs), approved and replanned — sha256 digests of the approved plan and of the fresh one the run asks about now, both None for replanned when the re-plan itself failed — and differs, whether the fresh change set differs, also None when there was no fresh plan to compareAn approved plan could not be applied as approvedLocal onlyDelete the run file
Run logrun.finishedStatus and shipped artifact namesThe closing stateLocal onlyDelete the run file
EdgeRate-limit keyPer-IP counter under the RATE_LIMITER binding, 30 requests per 60 secondsAbuse preventionCloudflare's own lifecycle; we do not set itExpires with the counter's own window
EdgeTurnstile tokenThe captcha solve posted with a join, checked against TURNSTILE_SECRET. With that secret unset there is no captcha port at all, and ENV=production then refuses every join — fail-closedStops automated signupsHeld per request; not stored in D1Not retained by us
EdgeOrigin headerChecked against the site's origins on every write; refused 403 otherwiseStops cross-origin writesHeld per request; not stored in D1Not retained by us
EdgeAuthorization: Bearer ADMIN_TOKENThe export credential on GET /v1/waitlist/admin/export.csvGates the CSV exportHeld per request; the secret itself is a Worker bindingRotating the secret revokes it going forward. It does not retract a header already captured in a Cloudflare request log; no purge path for those exists in this repository
EdgeCloudflare request logs[observability] enabled = true, so the platform retains request logsPlatform operabilityCloudflare's retention; no value is set in this repoCloudflare's own lifecycle
EdgeWorker secretsADMIN_TOKEN, TURNSTILE_SECRET, OWLPOST_API_KEY, RESEND_API_KEY, optional MAIL_THEMEMail, captcha and export accessHeld as bindings for the life of the deploymentwrangler secret put replaces; deletion is Cloudflare's
EdgeMail transportThe address and template are handed to Owlpost or Resend to send confirm mailSending requires a mailerThe mailer's own retentionA request to that mailer

Waitlist

Declaration: privacy/waitlist.toml.

The only hosted module written, and it is not deployed. It is a Cloudflare Worker at api.keepshipping.run over a D1 database, and it cannot be serving: wrangler.toml binds database_id = "0000...0000", a documented placeholder, and no deploy workflow exists in .github/workflows/. No waitlist entry has therefore ever been collected, and the retention and erasure rows below describe a design rather than a live corpus.

Its store is waitlist_entries (the 13 rows in the table above), plus two further tables. waitlist_position_lock holds product, updated_at and next_position, and its rows migration 0004 records as never pruned. Whether waitlist_send_cooldown holds personal data is not verifiable from this repository: its only definition here is migration 0003, subject TEXT PRIMARY KEY, last_sent_at TEXT NOT NULL, and in a module that mails confirm links subject is plausibly the recipient address. The code that writes it is in cratefield-module-waitlist: it is on crates.io and cargo fetch resolves it, but its source is not in this working tree, so its behaviour cannot be read from here without fetching. Confirm against that crate before launch; this document does not assert either way.

Retention is not running, and the sweep itself is upstream. The #[event(scheduled)] handler in src/lib.rs is unwired — wrangler.toml declares no crons trigger — so nothing fires. The pending-entry sweep it would call is cratefield-module-waitlist's design, on a retention_days_pending clock; that crate is on crates.io and resolvable, but its source is not in this working tree, so the only evidence in this repository is a doc comment on the handler, which is a claim rather than a thing you can point at. And retention_days_pending is a setting that no [vars] entry in this repository supplies, so the window's value is not even visible here. Both are gaps, listed below.

Approvals

Declaration: privacy/approvals.toml.

No hosted approvals service is deployed. What exists is the in-process adapter ks-hosted-approvals, whose ticket store is a Mutex<BTreeMap<..>> — in memory, per process, with no database and no network client. The store holds exactly Entry { seq, request, verdict, channel }, so every field marked "process lifetime" above dies with the process, and an access or erasure request needs no restart and no operator action: the data was never stored in the first place. The two request-scoped fields, and the four that only render the review page, are the exceptions and are never stored at all.

The identity in the core type is a handle plus a kind. Identity is { handle, kind } where kind is Human or Service — no email, no display name, no avatar, no account id. GitHubSession carries a GitHub login and its constructor is an unrestricted pub fn new(login: impl Into<String>): the restriction to the venture's OAuth exchange is a documented intent on the type, not a constraint any code enforces here, and no OAuth exchange exists in this repository. Slack answers resolve through (team_id, user_id) to a handle, and only the handle is kept.

Run logs

Declaration: privacy/runlog.toml.

Local only. Run events are appended as hash-chained JSONL under .keepshipping/runs on the runner. No hosted run-log viewer exists.

Two redactions stand between a run and its log, and both run before an adapter ever sees an event. RunEvent::redacted scrubs every variant, spelled out field by field on purpose, because an or-pattern binds only the fields every arm shares — which is how approver went unscrubbed the first time this was written. Underneath, VALUE_ALLOWLIST names exactly nine attribute paths whose values may leave (count, desired_count, instance_type, instance_class, min_size, max_size, node_count, replicas, engine_version), matched against the whole path and capped at 64 bytes. Everything else renders (withheld); a summarised plan renders names only.

Access and erasure requests

Write to contact@keepshipping.run — the address the venture's mail already carries, and the one SECURITY.md uses for disclosure.

Today, an erasure on the waitlist is manual: an operator runs wrangler d1 execute against the keepshipping-waitlist database. There is no self-service route and no request queue; the person who writes has to be the person who can act. Note that the venture README.md says the API "deletes confirmed-or-not entries after the retention window" — that sentence is stale: no crons trigger is wired, so no deletion happens on any window. The window's value is not even set in this repository.

Once cratefield-module-privacy is wired, it carries that out as

UPDATE waitlist_entries SET email = NULL, email_normalized = NULL, answers = NULL WHERE id = ?

Anonymise, not delete, and the reason is structural rather than cautious. Migration 0005_waitlist_anonymisable_entry.sql states it: position is a dense per-product join order that is never recomputed and referrals is a credit already granted to somebody else, so deleting a confirmed row changes a count other people can see and orphans every referred_by that points at its code. Keeping the row and taking the person out of it answers the request without rewriting anyone else's place in the queue. The same migration is why email and email_normalized are nullable: without it, the statement the declaration promises would have failed on the first request.

What never leaves

Read the closed list in ADR 0005 — "What never leaves the runner". It is not restated here, because a partial copy of a closed list is a list that will drift.

The three points a reader of this document most needs: plan bytes and their raw values never cross (there is no hosted adapter for them, ever); customer secrets and environment variable values never cross, though a variable name may leave as an Establishment marker; and what a hosted approval service does receive is the plan digest plus the masked PlanSummary — resource addresses and non-secret attribute values — which ADR 0005 itself calls a real disclosure.

Gaps

None of these are done. Following SECURITY.md, they are written as gaps rather than described as controls.

  1. The waitlist is not deployed. wrangler.toml binds a placeholder database_id and no deploy workflow exists, so the module cannot be serving and no entry has been collected. (#146)

  2. The retention sweep has no schedule, and the sweep is upstream. A #[event(scheduled)] handler exists but wrangler.toml has no crons trigger, and the sweep itself is cratefield-module-waitlist's, whose source is not in this working tree. Nothing is swept. (#151)

  3. retention_days_pending is unset in this repository, so even once a cron fires the window's value is not visible here. It is a cratefield-module-waitlist setting. (#146)

  4. Whether waitlist_send_cooldown holds personal data cannot be read from this working tree; migration 0003 only says subject TEXT, and the crate that writes it is on crates.io but its source is not here. Confirm against cratefield-module-waitlist before launch. (#146)

  5. The waitlist answers field list is undecided; no answers form exists, and every field will be named here before the form goes live. (#146)

  6. GitHubSession::new is an unrestricted public constructor. The documented intent that only the venture's OAuth exchange may build one is not enforced by any code here, and no OAuth exchange exists in this repository. (#151)

  7. Hosted approvals, the run-log viewer and the blocks index are not deployed. Of the four hosted surfaces in ARCHITECTURE.md, only the waitlist is written. (#151)

  8. cratefield-module-privacy is not a dependency of this repository. The declarations are the contract, committed ahead of the wiring; nothing reads them yet. (#151)

  9. The site's own privacy notice is a separate document, in Keep-Shipping/website (website#4). This document is its input, not the notice; nothing here is published on the site yet.

See also