Current custody: Sealed artifacts have an off-machine identity and storage path.
Date: 26 August 2026 Decision owner: Sam Scope: sealed research artifacts only
Why this exists
A sealed research artifact is not safely held if its only copy remains on the machine that produced it. The custody path therefore keeps an off-machine copy in the production Supabase project while preserving a database fact that can be compared with the original bytes later.
This is custody, not publication. The research-custody Storage bucket is private. The uploader does not make an object public, deploy application code, activate research, or change any trading state.
What is recorded
Migration 20260826190000_research_custody_objects_v1.sql adds research.custody_objects_v1. Each row binds:
- the private-bucket object path;
- the local SHA-256 and byte count;
- content type, artifact kind, and artifact reference;
- the recorder identity and timestamp; and
- a database-derived
record_hashover the stable identity fields.
research.custody_objects_v1 is owned by postgres, has RLS enabled and forced, and grants no table access to the repository's browser, runtime, lane, writer, or custodian roles. public, anon, authenticated, and service_role also have no research schema usage. Reads occur only inside owner-executed SECURITY DEFINER functions, following the existing first-hour custody pattern.
The only recorder EXECUTE grant belongs to the NOLOGIN/NOINHERIT paper_first_hour_corpus_custodian role. The recorder calls the existing first-hour custodian guard, which also requires session_user to be paper_first_hour_corpus_custodian_gateway. The direct PostgreSQL client authenticates as that LOGIN/NOINHERIT gateway and performs SET LOCAL ROLE paper_first_hour_corpus_custodian before calling the recorder. The recorder never updates an existing path: an identical call returns the existing row, while a reused path with a different SHA-256 or metadata is rejected. The Supabase service-role key is used only for Storage REST and never for PostgREST or the research schema.
Storage objects use this address:
<artifact_kind>/<sha256-of-local-bytes>/<basename>The uploader creates the bucket if absent, requests a private bucket with a per-file limit of at least 2 GiB when the Storage API/project plan permits it, and reads the bucket back to confirm it is private. Uploads use upsert=false and store the SHA-256 in object metadata. An existing object is accepted only when that stored SHA-256 agrees. Every accepted object is then downloaded through the authenticated Storage endpoint and hashed again before the custody row is recorded.
Operator ritual
Apply the migration through the normal reviewed migration release first. Do not put either credential in a shell history line. Load SUPABASE_URL, [sensitive credential], and RESEARCH_CUSTODY_DATABASE_URL into the current shell using the operator's secret-handling procedure, then run the command under a clean environment. The database URL must be a direct port-5432 Supabase PostgreSQL URL for the same project and must authenticate as paper_first_hour_corpus_custodian_gateway (not as postgres, service_role, or the NOLOGIN custodian):
env -i \
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" \
SUPABASE_URL="$SUPABASE_URL" \
[sensitive credential]="$[sensitive credential]" \
RESEARCH_CUSTODY_DATABASE_URL="$RESEARCH_CUSTODY_DATABASE_URL" \
node scripts/custody-upload.mjs \
--bucket research-custody \
--kind <artifact_kind> \
--ref <artifact_ref> \
--file <absolute-path> \
--dry-runApart from PATH, which locates node, the script allows exactly those three task variables and refuses any fourth. Dry-run reads and hashes the local files but constructs neither a Storage client nor a PostgreSQL client, sends no request, and reports "mutation":false and "verified":false.
Review the paths, hashes, byte counts, kind, and reference in that JSON. Remove only --dry-run to perform the custody operation. A successful write receipt has "mutation":true, top-level "verified":true, and "verified":true for every object. Keep that receipt with the research packet; it contains identities only and never contains the service-role key.
Verify a local file against custody
First derive the local facts without making a network call:
shasum -a 256 <absolute-path>
stat -f '%z bytes' <absolute-path>Its expected object_path is <artifact_kind>/<sha256>/<basename>. In the Supabase SQL editor's owner context, read the matching database fact (ordinary service and lane roles deliberately cannot run this SELECT):
select
object_path,
sha256,
byte_count,
content_type,
artifact_kind,
artifact_ref,
recorded_at,
recorded_by,
record_hash
from research.custody_objects_v1
where object_path = '<artifact_kind>/<sha256>/<basename>';The row must have the exact local SHA-256 and byte count. To verify the off-machine object bytes as well, rerun the uploader with the same arguments and without --dry-run: upsert=false prevents replacement, the stored metadata is checked, the private object is downloaded and re-hashed, and the identical recorder call returns the existing row. Accept the verification only when the resulting JSON again reports "verified":true.
An absent row, a different hash or byte count, missing/different object metadata, a failed download, or a receipt that is not verified is a custody failure. Do not rename, overwrite, or manually repair the object in place; investigate and preserve the conflicting evidence.