M3: Production backends, identity, delegated tokens, limits and load testing Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: openxet serve runs as N stateless nodes on Postgres and an S3,
GCS or Azure bucket, serves downloads from range-bound presigned URLs
wherever the store proves it binds a range, accepts OIDC identities and an
operator's webhook authorizer, issues revocable access tokens and
narrower delegated tokens for agents, enforces per-tenant rate limits,
quotas and memory shares, survives a rolling upgrade and a key rotation
under load, and proves all of it with a reproducible load harness whose
results are published.
Architecture: No new server crate for storage or the index: Postgres
is a second implementation of the existing Index, Repos and
GitStore traits in openxet-index, and an object_store-backed
ObjectBucket is a second Bucket in openxet-storage, both proven by
backend-agnostic conformance suites that SQLite and LocalBucket already
pass. Range-bound presigning is OpenXet's own small signer per store
family (object_store's signer can't bind a Range header), gated per
pool by ADR 0002's probe. Identity grows in openxet-auth (OIDC, the
webhook authorizer, access and delegated tokens) behind the M2 seams;
limits live in openxet-http so the CAS and the Hub share them. A
native load generator (crates/loadgen) and an orchestrator with
scenarios, thresholds and a multi-node compose stack (loadtest/) are
the M3 test rig.
Tech Stack: Rust 1.97 (edition 2024); axum 0.8, tokio; sqlx 0.9 with
the postgres feature (Postgres 17 in CI); object_store 0.14 (S3, GCS,
Azure); hmac/sha2 (already in openxet-auth) for SigV4 and GCS V4
presigning; jsonwebtoken 11 on aws-lc-rs for OIDC; governor 0.10
for rate limits; arc-swap 1.9 for hot key sets; reqwest 0.13 (rustls,
post-quantum key exchange preferred since #89); proptest, criterion;
pytest + uv with huggingface_hub 2.2.0 and hf_xet 1.7.0; Docker
Compose with MinIO, Garage, fake-gcs-server, Azurite, Postgres 17 and
HAProxy 3 for CI.
Spec: docs/superpowers/specs/2026-10-23-openxet-design.md (§5.2,
§5.3, §5.4, §5.5, §5.8, §5.9, §7.1, §12 M3, §15 "What this adds to the
core", §16 Performance and Operations, §17 rulings M1-R1, M1-R8, M1-R24,
M1-R25, M1-R29, M1-R30, M1-R32, M1-R37, M1-R39, M1-R40, M1-R42, M1-R43,
M2-R3), with ADR 0002 (range-bound URLs and the probe; its "Open, owned
by M3" list), ADR 0007 (CAS tokens and key sets), ADR 0011 (per-key alg
binding, the rotation drill), ADR 0012 (user credentials, the authorizer
seam), ADR 0013 (where M3 sits) and ADR 0014 (agents can never widen
their own limits). Issues: #43, #47, #48, #50, #54, #55, #57, #65.
Where M3 sits
- Order (ADR 0013): agent workspaces steps 1 and 2 are merged. M3 comes before workspace steps 4 to 6, whose hosted and multi-tenant promises need M3's backends, OIDC and issued tokens; step 6 builds on Tasks 13 and 14 here (delegated tokens) and on M4's audit log. Step 3 (the mount) needs nothing from M3 and may run alongside it.
- Already shipped: hybrid post-quantum TLS key exchange in every
rustls client (#66, commit 5d7fe5e,
crates/openxet/tests/post_quantum_tls.rs). The design's §12 M3 bullet for it is done; Task 23 marks it so. - Issue placement:
- #43, #47, #48, #55, #57 and #65 are M3 deliverables (Tasks 9, 5, 9, 19 to 22, 15, 10 and 22).
- #54 fits M3 and gets Task 18: memory per request is what limits concurrent clients per node, which the multi-node and soak scenarios measure, and its owner comment already ties it to #43 and #55. Its streaming-ingest half is gated on an upstream check (Task 18 step 1), so it cannot block the milestone.
- #50 is labelled M8 on GitHub, but design §15 lists it as a core addition and its status comment ties it to "the hosted rate limits", which are M3's. Task 17 builds the mechanism; the policy is an owner decision (see the end of this plan).
Global Constraints
Protocol and compatibility
- Nothing on the wire changes for stock clients except the URLs inside reconstruction answers, which become native presigned URLs on a pool that passed the probe. Every fetch entry still has exactly one range and one URL (ADR 0002 rule 1).
- URL lifetime stays 30 min to 60 min, default 60 min (ADR 0002 rule 5, M1-R42), on presigned URLs too.
xet-corecrates stay at=1.7.0;xet_core_structuresonly incrates/protocol,xet_dataand friends only incrates/client(scripts/check-client-core-ban.sh).crates/loadgenis not a server crate but takes onlyopenxet-protocol, neverxet-data, so it can drive request rates the client core would shape.- New dependencies take their latest stable release (M1-R15). At the time
of writing:
object_store0.14.2,jsonwebtoken11.1.0,governor0.10.4,arc-swap1.9.3,testcontainers0.28.0 (dev only); checkcargo searchwhen adding, andcargo deny checkmust pass.
Security (design §5.9, every row a release blocker)
- Every new credential kind (OIDC, access, delegated) and every new route
lands in the existing matrices:
no_unauthenticated_reads,credential_scoped_to_repo,responses_never_echo_credentialandcredentials_never_loggedgain rows, never exceptions. - Requirement 3 covers the new caches: the JWKS cache, the webhook decision cache, the access-token cache and the delegated-usage cache are keyed by the caller's credential or subject and fail closed: a cache that can't be refreshed denies, it never allows.
- Requirement 2 for presigned URLs: one xorb, one range, one expiry, and only after the caller's scope check (ADR 0002 rule 2). No presigned URL ever comes from a pool whose probe hasn't passed in this process.
- ADR 0014 item 3 is a security property: a delegated token can never widen its scope, extend its expiry, raise its caps, mint a token or approve anything. Its tests go in the security map.
- Secrets (OIDC tokens, access and delegated token secrets, the webhook secret, object-store credentials, presigned query strings) are never logged or echoed; logs carry route templates only (M1-R20).
- R21/R26: no public text describes or hints at an unfixed third-party security weakness. Probe results that show a store failing to bind a range are reported as "serves through the transfer role", with the check that failed, and nothing more.
Durability (non-negotiables 2, 3 and 5)
- The bucket stays the source of truth on every backend: registration and ownership records, purge records, and the new token, revocation and tenant-limit records reach the bucket before the index and before the client is answered.
- Nothing is deleted outside GC. Probe objects and staging objects
(Task 18) live under reserved prefixes that
list(kind)never returns and that M4's GC sweeps; a probe object is reused, never deleted. - Any node serves any request. No task may add node-local state that a request depends on, except caches that fail closed and the per-node rate limiters of M3-R10.
Process
- One branch and PR per task (
m3/tN-<slug>), squash-merged; parallel tasks run in their own worktrees, at most the number of concurrent cargo builds the machine allows (the owner's parallel-worktree rule). A task edits only the files it owns below; shared files (Cargo.toml,Cargo.lock,docs/SUMMARY.md,.github/workflows/ci.yml,AGENTS.md,CHANGELOG.md,docs/reference/config.md,docs/reference/security-tests.md) are edited by the task that needs them and rebased by whoever merges second. - Commits:
git commit -s, Conventional subject with a component scope, ending with theCo-Authored-Bytrailer of the model that wrote it. - TDD: RED before GREEN; never weaken a test, the compatibility suite
least of all. Every new CLI command has
--jsonwith a versioned schema, documented exit codes,--dry-runwhere it changes state, and an entry in the agent leg'sJSON_COMMANDS. Every new route is built through the route builder, hasutoipaannotations (M2-R15) and a structured error with ahint. - Migrations are numbered at merge time: a task that adds one takes the
next free number on
mainwhen it rebases, in bothcrates/index/migrations/andcrates/index/migrations-postgres/, and every one is an expand migration (M3-R6) unless this plan says otherwise. None in M3 is a contract migration. - Tests that need a service (Postgres, an emulator) read its URL from an
OPENXET_TEST_*variable and skip with a printed reason when it is unset; CI setsOPENXET_REQUIRE_SERVICES=1, under which a missing variable is a failure, so no CI job passes by skipping. - Review gates: a fresh reviewer per task against this plan and the
spec; every security test named here is in
docs/reference/security-tests.mdandscripts/check-security-tests-map.pypasses.
Review Focus
These are the conditions a user is most likely to hit that the spec implies but no feature test exercises naturally. Each is pinned by a test in the task that owns it.
- Two nodes registering the same xorb or shard at once on Postgres.
SQLite's single writer hid every write race M1 and M2 have. Two
uploads of one file through two nodes must both succeed (one stores,
one finds it stored), never a
500from a unique violation or a serialization failure, and the registration sequence keeps M1-R37's lowest-(seq, shard, tenant)row. Pinned byconcurrent_register_same_shard_two_poolsandconcurrent_put_xorb_two_pools(Task 3) andconcurrent_hub_commits_two_pools_one_winner(Task 4). - An S3-compatible store that accepts
If-None-Match: *but ignores it. Registration records rely on create-if-absent; a store that silently overwrites would let two writers' records diverge. The server must refuse to start on such a pool, naming the check and the setting, never run on it. Pinned bystart_refuses_store_without_conditional_create(Task 6). - Downloading right after uploading, with a read replica.
hf uploadthenhf downloadlands on a replica a few hundred milliseconds behind. The read must not404: a replica miss is read again from the primary. Pinned byreplica_miss_reads_primary(Task 8). - The identity provider rotates its signing key, or its JWKS endpoint
is down. A token signed with a new
kidis accepted after one refresh; an outage keeps serving with the last keys up tojwks_max_stale, then answers503 authn_unavailablewith a hint, never accepting an unverifiable token. Pinned byjwks_unknown_kid_refreshes_onceandjwks_outage_serves_cached_then_fails_closed(Task 11). - A stock client under default limits, and one over them. A default
hf uploadandhf downloadof a 4 GiB file never sees a429, a quota error or a503from limits; a client pushed over its rate gets429withRetry-After,hf_xetretries, and the transfer completes. Pinned bytest_default_limits_never_throttle_stock_clientandtest_rate_limited_client_recovers(Task 15).
Decisions this plan makes (rulings, recorded in design §17 by Task 1)
- M3-R1. Where the code goes. Postgres implements
Index,ReposandGitStoreincrates/index/src/postgres.rsandcrates/index/src/postgres/{git,repos,tokens,limits}.rs, with its migrations incrates/index/migrations-postgres/(SQLite's stay incrates/index/migrations/).ObjectBucketlives incrates/storage/src/object.rs, the presigners and the probe incrates/storage/src/presign/. OIDC, the webhook authorizer, access and delegated tokens live incrates/auth/src/{oidc,webhook,access,delegation}.rs; limits incrates/http/src/limits.rs. The native load generator is a workspace cratecrates/loadgen(publish = false, binaryopenxet-loadgen); scenarios, compose files, thresholds and the orchestrator live inloadtest/. - M3-R2. The Postgres index. One schema mirroring SQLite's tables
(hashes as
BYTEA, times asBIGINTUnix seconds).xorbs,xorb_layouts,xorb_owners,file_scopesanddeduparePARTITION BY HASHon their hash column with 16 partitions (design §5.8's partitioning by hash prefix; changing the count is a migration). The registration sequence is a PostgresSEQUENCE. Concurrency: inserts of content-addressed rows useON CONFLICT DO NOTHINGand read back, so a lost race is success; ref commits lock the repository row (SELECT … FOR UPDATE) inside the transaction that checks the fence (ADR 0004 item 3); a serialization failure (40001/40P01) is retried up to 3 times, then answered503 index_busywithRetry-After: 1. Configuration:[index] url(sqlite:path orpostgres://, default the data directory'sindex.sqlite),max_connections(default 4 × CPUs, bounds 2 to 1024),read_url(optional replica). - M3-R3. Replica reads. With
index.read_url, reconstruction, file heads, listings and dedup lookups read from the replica. A replica answer of "absent" is read again from the primary before anyone is told "absent", so a replica never hides an acknowledged write (requirement 7). Writes, ref snapshots for Git and every authorization-bearing read (tokens, revocations, limits) use the primary. - M3-R4. Object-store buckets.
[storage] urlselects the backend:file:///path(the M1LocalBucket, default{data_dir}/bucket),s3://bucket/prefix,gs://bucket/prefix,az://container/prefix, withobject_store's own builder options under[storage.options]and credentials from the environment or instance identity, never from the config file in plain text (a*_fileoption reads a secret file, as the transfer key does). The key layout is M1's, under the prefix.createisPutMode::Create; at start-up a conditional-create probe createsprobe/{node uuid}/createtwice and requires the second call to report "exists"; a store failing it is refused withstorage_unsafeand a hint. A remote store requires an explicitcas.transfer_key_fileand an explicit CAS-token key (M1-R29). - M3-R5. Range-bound presigning. OpenXet signs presigned GETs
itself, so
Rangeis a signed header: SigV4 query signing withX-Amz-SignedHeaders=host;rangefors3://(AWS S3, R2, MinIO, Garage), and GCS V4 signing withX-Goog-SignedHeaders=host;rangeforgs://(service-account key or HMAC key). Azure SAS has no field that binds a range, soaz://pools have no presigner and serve through the transfer role. Each pool's range-binding probe (ADR 0002 rule 3) runs at start-up and inopenxet config check --probe; only a pool whose three checks pass serves direct URLs, and the result is logged, exported as theopenxet_storage_direct_downloadsgauge and shown byconfig check. A presigned URL's lifetime ismin(cas.url_lifetime, credential expiry - 60 s); when that falls under ADR 0002's 30 min floor, the presigner refreshes its credentials first, and if it still can't reach the floor that answer's URLs are transfer URLs. - M3-R6. Schema compatibility for rolling upgrades (issue #47,
ADR 0017). Migrations are expand (additive: new tables, nullable or
defaulted columns, new indexes) unless the file's first line is
-- openxet: contract. A contract migration only drops or narrows what no binary of the previous release reads, and raisesschema_compat.min_binary_epoch. Each binary carriesopenxet_index::SCHEMA_EPOCH. A node opens a database that carries migrations it doesn't know (sqlxset_ignore_missing(true)) as long asmin_binary_epoch <= SCHEMA_EPOCH, and refuses withschema_too_new, naming the version to upgrade to, otherwise. Nodes of two adjacent releases therefore share one database through a rolling upgrade.index.migrate = "auto"(default) applies pending migrations at start-up under Postgres' advisory lock;"off"leaves it toopenxet migrate [--dry-run] [--json]. - M3-R7. OIDC.
[[auth.oidc]]entries:name(subject prefix),issuer(exact match, discovered through{issuer}/.well-known/openid-configuration),audiences(required, one must matchaud),subject_claim(defaultsub),name_claim(defaultpreferred_username, falling back to the subject),email_claim(defaultemail),claims(names of string or string-list claims copied into the principal's claims;orgsfeedswhoami-v2). A principal's subject is{name}:{sub}, so two issuers never share a subject. Acceptedalgs are those of the JWKS key thekidnames, from RS256, PS256, ES256, ES384 and EdDSA;noneand HMAC are always refused.expis exact,nbfandiatallowMAX_CLOCK_SKEW. JWKS refreshes every 10 min, and on an unknownkidat most once per 30 s per issuer; after a failed refresh the last keys serve up tojwks_max_stale(default24h), then requests get503 authn_unavailable.Authenticator::authenticatebecomesasync. - M3-R8. The webhook authorizer.
[auth.webhook]url,secret_file,timeout(default2s),allow_ttl(default30s),deny_ttl(default5s). OpenXet POSTs{"schema": "openxet.authz.v1", "subject", "claims", "action", "namespace", "name", "delegated_by"}withX-Openxet-Signature: sha256={hex HMAC of the body}andX-Openxet-Timestamp; the answer is{"allow": bool}plus optional"ttl_seconds"(capped by the configured TTLs). Cache key: the full request body's SHA-256. Timeouts, non-2xxanswers and unparseable bodies areAuthzError(503 authz_unavailable, never allow).auth.authorizer = "rules" | "webhook" | "rules+webhook"(defaultrules): with both, a rule grant allows without a call, and the webhook decides the rest. - M3-R9. OpenXet access tokens (design §5.4, ADR 0016). A secret is
oxt_plus 43 base64url characters (M1-R28); its record holds the SHA-256 of the secret, a UUIDv7 id, the bound subject, the claims snapshot, the issuer name,created_at,expires_at(default 90 days,auth.access_token_max_ttldefault365d, bounds1dto3650d),revoked_atandlast_used_at(index only, updated at most once a minute). Issue:POST /api/openxet/v1/tokensauthenticated by an OIDC token (an access token can't mint another access token, so every one traces to an identity-provider proof), orPOST /api/openxet/v1/admin/tokensby a subject inauth.adminsfor any subject and claims (a platform's backend). The record is written totokens/{id}in the bucket, then the index; revocation writestoken-revocations/{id}first. Each use re-asks the authorizer. Lookup: static tokens, then a per-node positive cache (5 s), then the primary index; unknown secrets are never cached. - M3-R10. Delegated tokens (design §7.1, ADR 0014, ADR 0016). Minted
by
POST /api/openxet/v1/tokens/delegatewith an OIDC or access token, never a delegated one; revoking the access token it was minted with revokes it too. Body:repos(a list of{namespace}/{name}, at most 100, no globs),mode(readorwrite),ttl(default8h, at mostauth.delegated_max_ttl, default24h, bounds1hto720h),max_bytesandmax_ops(optional),name(for the audit log). At mint, the parent must be allowedmodeon every repository now. On use, a request is allowed only if the target is listed, the action is withinmode(createnever), the parent subject is still allowed by the authorizer, and the token is not expired, revoked or exhausted. A CAS token minted through a delegated token hassub = "delegated:{id}"(a reserved prefix; static tokens can't use it) and lasts at most what the delegated token has left. Ops are authenticated requests made with the token or its CAS tokens; bytes are upload bodies accepted plus the bytes of the ranges a reconstruction answer grants. Usage is batched per node and flushed every second; exhaustion and revocation are read from a per-node cache at most 2 s old, so a cap overshoots by at most two seconds of the token's own traffic, which the docs say. This is a cap on granted bytes, not on egress: a replayed URL isn't counted (ADR 0013's caveat). Token records reach the bucket like access tokens; afterreindexa capped token comes back exhausted, since usage counters are index-only. - M3-R11. Rate limits. Per-node GCRA limiters (
governor), keyed per tenant and per credential, configured under[limits]:tenant_requests_per_second(default 2000, burst 4000),credential_requests_per_second(default 1000, burst 2000),max_in_flight_per_credential(default 512), andauth_failures_per_minuteper client IP (default 60). Over a limit:429 rate_limitedwithRetry-Afterin whole seconds and a hint. The probes are exempt. Limits are per node on purpose: they protect a node's resources and need no shared state (non-negotiable 5); the deployment-wide figure is the per-node one times the nodes, which the docs say. Global, byte-denominated limits are quotas (M3-R12). Task 15 confirms with the stock client that the defaults never throttle a defaulthf_xettransfer, and lowers or raises them with the numbers if not. - M3-R12. Quotas. A tenant's stored bytes are the sizes of the xorbs
it owns (M1-R39), counted alone, as stand-alone billing will be (§15):
a counter maintained in the transaction that records an owner, and
rebuilt from
xorb-owners/records byreindex. A xorb upload that would take its tenant pastquota_bytesanswers403 quota_exceededwith the quota, the usage and a hint, before the body is read; reads never stop.[limits] default_tenant_quota(default unlimited); per tenant overrides of the quota and the rate limits throughPUT /api/openxet/v1/admin/tenants/{tenant}/limits, written totenant-limits/{sha256(tenant)}in the bucket, then the index, and read by nodes through a 10 s cache. - M3-R13. Memory shares (M1-R40, issue #57). One credential may
hold at most
server.credential_memory_shareof the budget (default0.25, bounds0.01to1), one tenant at mostserver.tenant_memory_share(default0.5). A reservation over its share waits by the same rules as one over the budget, then gets503 memory_busy. A reconstruction reserves once, from the upper bound its stored term count gives, and a ranged request reserves only its range's terms, so the413 answer_too_largehint is only ever given to an answer that is too large for any range. - M3-R14. Index round trips outside CPU permits (issue #48; amends
M1-R8 and M1-R25). Shard verification's chunk tables and a
reconstruction's chunk layouts are loaded before the
validatepermit is taken, with their bytes reserved from the memory budget first (from the stored xorb count × 320 KiB and the term count), so the memory bound moves from "permits × tables" to the budget, and anindex_iosemaphore sized toindex.max_connectionsbounds the loads. The M1 testchunk_tables_are_loaded_under_a_validation_permitis replaced bychunk_tables_are_reserved_before_loadingandcpu_permits_idle_during_index_round_trips_is_zero. - M3-R15. Key rotation (issue #65, ADR 0011). Each verification key
is bound to one
alg, taken from its key type (Ed25519:EdDSA), and a token whose headeralgdiffers from its key's is refused. CAS-token keys rotate with today's settings: add the new public key toauth.jwt_public_keyseverywhere, switchcas-token.keyand list the old public key, remove it afterauth.max_token_lifetime. Transfer keys gaincas.transfer_verify_key_files: keys accepted but never used to sign, so the transfer key rotates the same way, in one URL lifetime.openxet config checklists every key with itskidor fingerprint,algand age, and warns pastauth.key_max_age(default180d). - M3-R16. Load testing (issues #55, #43). Scenarios are TOML files
in
loadtest/scenarios/; thresholds inloadtest/thresholds.toml; results are JSON (openxet.loadtest.v1) with the machine, the git revision, the config and the raw latency histograms. The nightly run (CI, one runner, 3 nodes in compose, about 20 min) fails on any error outside the documented503/413/429, any acknowledged write missing after the node kill, memory over budget, or a p99 more than 2× its threshold. The release run (the owner's release rig, full scenarios, the 24 h soak) fails at the threshold itself. - M3-R17. Dedup influence under
global(issue #50; amends M1-R30; the policy is the owner's call, see the end of this plan). A dedup answer takes the newest visible shard registered by the caller's tenant that references the matched xorb, and only if there is none the newest shard of any tenant. Another tenant's registrations can then shape only the answers of tenants that never uploaded those chunks themselves.
Owner-run steps and credentials
CI never holds cloud accounts. Everything below has an emulator path in CI and an owner-run step for the real service; the owner-run results go into the docs with the date, the region and the version.
| What | CI (every PR or nightly) | Owner-run, needs |
|---|---|---|
| Range-binding probe (ADR 0002 "Open, owned by M3") | MinIO and Garage (S3), fake-gcs-server, Azurite; each emulator's outcome is recorded, never assumed (an emulator that doesn't check signatures exercises the fallback) | AWS S3 (an IAM user or role with s3:GetObject/PutObject on a test bucket), Cloudflare R2 (an API token), GCS (a service account key or HMAC key), Azure (a storage account and SAS-capable key): openxet config check --probe --json per store, Task 7 step 6 |
| Bucket conformance suite | MinIO, Garage, fake-gcs-server, Azurite | the same four accounts, OPENXET_TEST_STORAGE_URL=… cargo nextest run -p openxet-storage -E 'binary(object_bucket)' |
| Signing benchmark (ADR 0002 consequences) | criterion locally, recorded with the machine | none (signing is local); the end-to-end direct-download timing against real S3 is part of the release run |
| Near-linear scaling 1 to N nodes | not meaningful on one runner (the nightly checks correctness only) | the release rig: N ≥ 4 node hosts, a Postgres host and an S3 bucket, or one cloud account to rent them |
| 24 h soak, release gate | a 30 min soak in the nightly | the release rig |
Dependency graph and file ownership
wave 1: T1 docs/ADRs · T2 index conformance · T6 object bucket · T10 key binding + transfer keys · T11 OIDC · T12 webhook · T18 answers (#54)
wave 2: T3 Postgres CAS tables (T2) · T7 presign + probe (T6)
wave 3: T4 Postgres repos/git (T3) · T5 migration policy (T3)
wave 4: T8 serve wiring + replica (T4, T5, T6) · T13 access tokens (T4, T11, T12)
wave 5: T9 permits/#48 (T8) · T14 delegated tokens (T13) · T15 rate limits + shares (T13) · T17 dedup influence (T4) · T19 load harness (T8)
wave 6: T16 quotas (T15) · T20 multi-node + #43 (T19, T7)
wave 7: T21 tenant abuse scenarios (T16, T20) · T22 key-rotation drill (T10, T20)
wave 8: T23 gates, results, docs and project state (all)
Tasks in one wave are independent of each other and run in parallel;
each starts once the tasks in parentheses are merged.
Wave 5's Tasks 9, 14 and 15 all touch crates/cas/src/reconstruction.rs,
in different steps of a request (permit order, usage charging,
reservation); merge them in the order 9, 15, 14 to keep rebases small.
Owned files:
docs/adr/0015..0018, design §5.4/§5.5/§5.8/§12/§17 T1
crates/index/src/conformance/, crates/index/tests/sqlite_conformance.rs T2
crates/index/src/postgres.rs, migrations-postgres/, tests/postgres_conformance.rs T3
crates/index/src/postgres/{git,repos}.rs T4
crates/index/src/{schema,lib}.rs (epoch), crates/openxet/src/migrate_cmd.rs T5
crates/storage/src/{object,conformance}.rs, tests/object_bucket.rs, loadtest/compose.emulators.yaml T6
crates/storage/src/presign/, crates/cas/src/answer.rs (URL source), crates/cas/benches/presign.rs T7
crates/openxet/src/{serve,config}.rs ([index], [storage]), crates/index/src/replica.rs T8
crates/cas/src/{shards,reconstruction,plan}.rs T9
crates/auth/src/{jwt,signed_url}.rs, crates/openxet/src/config_cmd.rs (keys) T10
crates/auth/src/oidc.rs, crates/auth/src/lib.rs (async authenticate) T11
crates/auth/src/webhook.rs, crates/auth/src/rules.rs (composition) T12
crates/auth/src/access.rs, crates/index/src/tokens.rs + both backends, crates/storage/src/records.rs (token records), crates/hub/src/tokens_api.rs, crates/openxet/src/token_cmd.rs T13
crates/auth/src/delegation.rs, crates/cas/src/usage.rs T14
crates/http/src/limits.rs, crates/cas/src/budget.rs T15
crates/index/src/limits.rs + both backends, crates/hub/src/admin.rs, crates/cas/src/xorbs.rs (quota check) T16
crates/cas/src/dedup.rs, Index::dedup_shard_xorbs T17
crates/cas/src/{answer,send_body}.rs (segments), crates/cas/src/xorbs.rs (streaming, if step 1 allows) T18
crates/loadgen/, loadtest/{scenarios,run.py,thresholds.toml,pyproject.toml} T19
loadtest/compose.yaml, loadtest/haproxy.cfg, loadtest/drills/kill_node.py T20
loadtest/scenarios/{many_tenants,abuse}.toml T21
loadtest/drills/key_rotation.py, docs/guides/key-rotation.md T22
docs/, AGENTS.md, CHANGELOG.md, .github/workflows/loadtest-nightly.yml T23 (plus each task's reference pages)
Task 1: Record M3's decisions (ADRs 0015 to 0018, design edits, security map rows)
Files:
- Create:
docs/adr/0015-production-backends-and-range-bound-presigning.md,docs/adr/0016-issued-and-delegated-tokens.md,docs/adr/0017-schema-compatibility-for-rolling-upgrades.md,docs/adr/0018-limits-quotas-and-memory-shares.md - Modify:
docs/superpowers/specs/2026-10-23-openxet-design.md(§5.4 OIDC, webhook, access and delegated tokens; §5.5 presigners and the conditional-create probe; §5.8 replicas and limits; §7.1 delegated tokens point at ADR 0016; §12 M3 links this plan and marks PQ TLS shipped; §17 rows M3-R1 to M3-R17),docs/adr/0002-one-range-bound-url-per-fetch-entry.md(consequences: "Open, owned by M3" points at Task 7),docs/adr/0007-cas-tokens-are-eddsa-jwts.md(amended: the reserveddelegated:subject prefix),docs/reference/security-tests.md(M3 rows,pending (M3, <file>)),docs/SUMMARY.md
Interfaces:
-
Produces: M3-R1 to M3-R17 as public text; ADRs 0015 to 0018 as their citations.
-
Step 1: Write the four ADRs from the template:
- 0015 from M3-R2 to M3-R5: context (ADR 0002's open list, §5.8's
topology,
object_store's signer can't signRange), decision, consequences (Azure serves through the transfer role until a SAS can bind a range; replicas never answer "absent" alone), alternatives (aws-sigv4from the AWS SDK: rejected, one signer per family is about 200 lines with published test vectors and avoids the SDK's dependency tree; unbound presigned URLs: rejected by ADR 0002; a distributed KV now: rejected, §5.8 keeps it for later). - 0016 from M3-R7 to M3-R10: why access tokens can't mint access tokens, why delegated tokens can't chain, why caps count granted bytes, why token records go to the bucket (losing the database must not lose anyone's access, design §5.3).
- 0017 from M3-R6: expand/contract with an epoch; alternatives (refuse unknown migrations, the M1 behaviour: blocks rolling upgrades; an N-ahead window without epochs: can't express a contract).
- 0018 from M3-R11 to M3-R13: per-node rate limits vs. global quotas and why; alternatives (shared counters in Postgres per request: a round trip on every request; a Redis tier: new state to run, against "nothing to babysit").
- 0015 from M3-R2 to M3-R5: context (ADR 0002's open list, §5.8's
topology,
-
Step 2: Edit the design and ADR amendments as listed above. Record M3-R14 and M3-R17 as amendments of M1-R8/M1-R25 and M1-R30 in §17, and add to §5.9 the new test names (Task 11, 13, 14 and 15 security tests) under requirements 2, 3 and 6.
-
Step 3: Security map. Add rows for every security test this plan names,
pending (M3, <file>), so the checker holds each task to its file. -
Step 4: Verify.
scripts/build-site.sh,python3 -I scripts/check-copy.py,python3 -I scripts/check-brand.pyand the security-map checks pass;git grep -n -i -E "weak|vulnerab|exploit" docs/adr/001[5-8]*shows only OpenXet's own policy. -
Step 5: Commit as
docs(adr): 0015 to 0018 production backends, issued tokens, schema compatibility, limits; design M3 rulings.
Task 2: A backend-agnostic index conformance suite
Files:
- Create:
crates/index/src/conformance/{mod,cas,repos,git}.rs(behind thetest-supportfeature),crates/index/tests/sqlite_conformance.rs - Modify:
crates/index/Cargo.toml(feature),crates/index/tests/{sqlite_index,repos,git_store,concurrency}.rs(cases that hold for any backend move into the suite; SQLite-only cases, such as the open lock and file modes, stay)
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
#[async_trait] pub trait Fixture: Send + Sync {
type Index: HubIndex;
async fn fresh(&self) -> Self::Index; // an empty, migrated index
async fn second_handle(&self, of: &Self::Index) -> Self::Index; // another pool on the same database: a second node
}
pub async fn run_all<F: Fixture>(f: &F); // every case, each on a fresh index, named in the panic message
pub async fn run_cas<F: Fixture>(f: &F);
pub async fn run_repos<F: Fixture>(f: &F);
pub async fn run_git<F: Fixture>(f: &F);
}
- Step 1: Move the cases. Each backend-agnostic test of the four
files becomes an
async fn case_<name>(f: &F)in the suite, unchanged in its assertions; add the two-handle cases Review Focus 1 names,concurrent_register_same_shard_two_pools,concurrent_put_xorb_two_poolsandconcurrent_hub_commits_two_pools_one_winner, each driving 16 concurrent calls split overfreshandsecond_handle. - Step 2: Run
cargo nextest run -p openxet-index. Expected: PASS on SQLite with the same test count plus the three new cases (SQLite's second handle is a secondSqliteIndex::openon one file). - Step 3: Commit as
test(index): backend-agnostic conformance suite, run on SQLite.
Task 3: Postgres index, CAS tables
Files:
- Create:
crates/index/src/postgres.rs,crates/index/migrations-postgres/0001_init.sql(the end state of SQLite's 0001 to 0007, partitioned per M3-R2),crates/index/tests/postgres_conformance.rs - Modify:
crates/index/Cargo.toml(sqlxpostgresfeature),crates/index/src/lib.rs(PostgresIndex,IndexError::Busy),.github/workflows/ci.yml(apostgres:17service on therustjob,OPENXET_TEST_POSTGRES_URL)
Interfaces:
- Consumes: Task 2's
Fixtureandrun_cas. - Produces:
#![allow(unused)]
fn main() {
pub struct PostgresIndex { /* PgPool, optional replica pool from Task 8 */ }
impl PostgresIndex {
pub async fn connect(url: &str, max_connections: u32, migrate: Migrate) -> Result<Self, IndexError>;
}
pub enum Migrate { Auto, Off } // Task 5 fills in the epoch check
impl Index for PostgresIndex { /* every method */ }
// IndexError::Busy after 3 retried serialization failures → CAS answers 503 index_busy, Retry-After: 1
}
- Step 1: Write the failing test
postgres_conformance.rs: aFixturethat creates a fresh database per case (CREATE DATABASE openxet_test_{uuid}on the service) and callsrun_cas; skips with the reason whenOPENXET_TEST_POSTGRES_URLis unset (fails underOPENXET_REQUIRE_SERVICES=1). Addserialization_failures_retry_then_busy(a fault hook makes the first three commits fail with40001: the fourth attempt's error isIndexError::Busy). - Step 2: Run
OPENXET_TEST_POSTGRES_URL=postgres://… cargo nextest run -p openxet-index -E 'binary(postgres_conformance)'. Expected: FAIL (noPostgresIndex). - Step 3: Implement the schema and every
Indexmethod. Chunk tables and layouts stay per-xorbBYTEAblobs, as in SQLite (§5.8: no per-chunk rows).register_shardis one transaction; owner, scope and dedup rows useON CONFLICT DO NOTHING; the M1-R37 "lowest(seq, shard, tenant)" row is kept withON CONFLICT … DO UPDATE … WHERE (excluded.seq, …) < (…). - Step 4: Run the suite and
cargo clippy. Expected: PASS; the CAS crate's tests unchanged. - Step 5: Benchmark
cargo bench -p openxet-cas --bench commit_throughputwith a Postgres variant (OPENXET_BENCH_POSTGRES_URL), recording shard and xorb registrations per second against §5.8's 200 and 2,000 per second inbenchmarks.mdwith the machine. - Step 6: Commit as
feat(index): Postgres index for xorbs, files, scopes and dedup.
Task 4: Postgres index, repositories, refs and packs
Files:
- Create:
crates/index/src/postgres/{repos,git}.rs - Modify:
crates/index/src/postgres.rs,crates/index/tests/postgres_conformance.rs(run_repos,run_git)
Interfaces:
-
Consumes: Task 3's
PostgresIndex. -
Produces:
impl Repos for PostgresIndex,impl GitStore for PostgresIndex, soPostgresIndex: HubIndex. -
Step 1: Write the failing test: enable
run_reposandrun_gitinpostgres_conformance.rs, pluslease_epoch_fences_a_stale_holder_across_pools(two pools; the holder whose lease was taken over can't publish a repack). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
commitlocks the repository row withSELECT … FOR UPDATE, checks everyRefUpdate.oldand thefull_repack_genfence, writes refs and theref_logrow in the same transaction (ADR 0004 item 3). The case-insensitive name key is a unique index onlower(namespace) || '/' || lower(name). -
Step 4: Run the suite. Expected: PASS, including
concurrent_hub_commits_two_pools_one_winner. -
Step 5: Commit as
feat(index): Postgres repositories, refs, packs and leases.
Task 5: Schema compatibility for rolling upgrades (issue #47)
Files:
- Create:
crates/index/src/schema.rs,crates/index/migrations/0008_schema_compat.sql,crates/index/migrations-postgres/0002_schema_compat.sql,crates/index/tests/schema_compat.rs,crates/index/tests/fixtures/next_release/(an expand and a contract migration used only by tests),crates/openxet/src/migrate_cmd.rs,crates/openxet/tests/migrate.rs - Modify:
crates/index/src/{lib,sqlite,postgres}.rs(open throughschema::open),crates/openxet/src/{cli,main}.rs,compat/tests/test_agent_cli.py(JSON_COMMANDS),docs/reference/{cli,exit-codes}.md
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
pub const SCHEMA_EPOCH: u32 = 1;
pub enum SchemaCheck { Current, Behind { pending: Vec<i64> }, AheadCompatible { unknown: Vec<i64> }, TooNew { min_binary_epoch: u32 } }
pub async fn check(pool: &AnyPoolRef<'_>) -> Result<SchemaCheck, IndexError>;
// IndexError::SchemaTooNew { min_binary_epoch } → serve exits with code `schema_too_new` and a hint naming the upgrade
}
-
openxet migrate [--dry-run] [--json](openxet.migrate.v1:{applied: [..], pending: [..], epoch, min_binary_epoch}). -
Step 1: Write the failing tests (both backends where noted):
older_binary_opens_database_with_expand_migration(apply the fixture's expand migration with a test migrator, then open with the shipped one:AheadCompatible, every conformance read works).older_binary_refuses_after_contract_migration(the fixture's contract migration raisesmin_binary_epochto 2:SchemaTooNew).migration_files_declare_their_kind(every shipped migration is expand unless marked, and a contract file is rejected unless it raises the epoch).concurrent_migrate_runs_once(Postgres: two nodes start together withmigrate = "auto"; each migration is applied once).migrate_dry_run_changes_nothing,migrate_json_schema(CLI).
-
Step 2: Run. Expected: FAIL.
-
Step 3: Implement
schema::openfor both backends withMigrator::set_ignore_missing(true)and the epoch check;index.migrateis read by Task 8, so this task takes the setting as a parameter. -
Step 4: Write the procedure in
docs/guides/rolling-upgrades.md(new; inSUMMARY.md): upgrade nodes one at a time, contract releases only after every node runs the previous release,openxet migrate --dry-runfirst. Task 20 runs it in the multi-node rig. -
Step 5: Run the tests and the agent leg. Expected: PASS.
-
Step 6: Commit as
feat(index): expand/contract schema epochs so two releases share one database (#47).
Task 6: ObjectBucket on S3, GCS and Azure, with the conditional-create probe
Files:
- Create:
crates/storage/src/object.rs,crates/storage/src/conformance.rs(behindtest-support),crates/storage/tests/object_bucket.rs,loadtest/compose.emulators.yaml(MinIO, Garage, fake-gcs-server, Azurite, each pinned by digest) - Modify:
crates/storage/Cargo.toml(object_storewithaws,gcp,azure),crates/storage/src/{lib,error}.rs,crates/storage/tests/local_bucket.rs(backend-agnostic cases move to the suite),.github/workflows/ci.yml(astorage-emulatorsjob)
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
pub struct ObjectBucket { /* Arc<dyn ObjectStore>, prefix, kind: StoreKind */ }
pub enum StoreKind { S3, Gcs, Azure }
impl ObjectBucket {
pub async fn open(url: &Url, options: &BTreeMap<String, String>) -> Result<Self, StorageError>;
pub async fn probe_conditional_create(&self, node: Uuid) -> Result<(), StorageError>; // StorageError::Unsafe { check, hint }
pub fn store(&self) -> (StoreKind, Arc<dyn ObjectStore>); // for Task 7's presigners
}
impl Bucket for ObjectBucket { /* every method */ }
pub async fn conformance::run_all(open: impl Fn() -> BoxFuture<'static, Arc<dyn Bucket>>);
}
- Step 1: Write the failing tests:
- the conformance suite from
local_bucket.rs's backend-agnostic cases (put idempotence, size conflict,createfirst-writer-wins,put_replaceonly forRepoMeta, ranges, streaming blocks of at mostSTREAM_BLOCK_BYTES,list(kind)skipping foreign names,delete), run againstLocalBucketand against each emulator named byOPENXET_TEST_STORAGE_URL(s3://…?endpoint=…,gs://…,az://…); start_refuses_store_without_conditional_create(a wrapper store that ignoresPutMode::Create:probe_conditional_createreturnsUnsafenaming the check andstorage.options);create_race_one_winner_on_object_store(32 concurrentcreates of one registration key: exactly onetrue, every reader sees its bytes);put_stream_uses_multipart_and_is_atomic(a stream failing halfway leaves no object under the key).
- the conformance suite from
- Step 2: Run with the emulators up (
docker compose -f loadtest/compose.emulators.yaml up -d). Expected: FAIL. - Step 3: Implement.
putof a content-addressed key:headfirst, thenPutMode::Overwrite(identical bytes);create:PutMode::Create, mappingAlreadyExists/PreconditiontoOk(false);put_replace: plain put (atomic on object stores);put_stream:put_multipartwith 8 MiB parts;get_to_file: a ranged stream to a temp file, synced, renamed. Record which emulator passes the conditional-create probe and the suite in the task's PR description; an emulator that can't is excluded from CI with the reason inci.yml, not patched around. - Step 4: Owner-run step (documented in the PR, not blocking merge): the suite against real AWS S3, R2, GCS and Azure with the owner's accounts.
- Step 5: Commit as
feat(storage): object-store buckets on S3, GCS and Azure with a conditional-create probe.
Task 7: Range-bound presigned downloads, the probe and the signing benchmark (ADR 0002)
Files:
- Create:
crates/storage/src/presign/{mod,sigv4,gcs_v4,probe}.rs,crates/storage/tests/presign.rs,crates/cas/benches/presign.rs,docs/reference/object-stores.md - Modify:
crates/cas/src/{answer,lib}.rs(the URL source),crates/cas/tests/reconstruction.rs,crates/openxet/src/config_cmd.rs(--probe),compat/tests/test_storage_only.py(direct-download legs),.github/workflows/ci.yml
Interfaces:
- Consumes: Task 6's
ObjectBucket::store. - Produces:
#![allow(unused)]
fn main() {
pub trait RangePresigner: Send + Sync + 'static {
// Synchronous: called inside the answer writer for each range.
fn presign_range(&self, key: &Key, start: u64, end_inclusive: u64, now: u64, lifetime: Duration) -> Result<Url, StorageError>;
fn credential_expiry(&self) -> Option<u64>; // M3-R5's lifetime cap
}
pub fn presigner_for(bucket: &ObjectBucket) -> Option<Arc<dyn RangePresigner>>; // None for Azure and LocalBucket
pub enum ProbeOutcome { Direct, Transfer { failed: ProbeCheck, status: Option<u16> } }
pub enum ProbeCheck { SignedRange206, OtherRange403, NoRange4xx, Setup }
pub async fn probe_range_binding(bucket: &ObjectBucket, signer: &dyn RangePresigner, http: &reqwest::Client) -> ProbeOutcome;
// openxet_cas: pub enum XorbUrlSource { Transfer(Arc<UrlSigner>), Direct { presigner: Arc<dyn RangePresigner>, fallback: Arc<UrlSigner> } }
}
- Step 1: Write the failing tests:
sigv4_matches_aws_test_vectors(AWS's published presigned-URL example, extended with arangesigned header and checked against MinIO accepting it) andgcs_v4_matches_reference(Google's V4 signing example);probe_passes_on_minio,probe_falls_back_on_unbound_signer(a signer that omitsrangefrom the signed headers:Transfer { failed: OtherRange403 }), one fake-store test perProbeCheck;direct_urls_only_after_probe_passed,every_direct_url_binds_its_range(each fetch entry's URL carriesrangein its signed headers and one range, ADR 0002's shape test),refreshed_answer_keeps_terms_and_ranges,presigned_lifetime_capped_by_credential_expiry;- compat:
test_download_direct_from_miniowithHF_XET_CLIENT_ENABLE_MULTIRANGE_FETCHINGunset andtrue, and withHF_XET_CLIENT_RECONSTRUCTION_API_VERSION=1; andtest_expired_direct_url_refreshes(S1 variant d: the first GETs see403, the client refreshes once and completes).
- Step 2: Run. Expected: FAIL.
- Step 3: Implement the signers (derive the SigV4 signing key
once per day and region, then one HMAC per URL), the probe (a fixed
64 KiB object at
probe/range-binding, created once and reused), andXorbUrlSourceinanswer.rsso the writer asks the source per range. - Step 4: Benchmark
cargo bench -p openxet-cas --bench presign: URLs per second per core for SigV4 and GCS V4 against the transfer signer, and the 100k-term reconstruction answer's build time in both modes; record inbenchmarks.mdwith the machine. The latency gate's p99 < 50 ms must still hold with direct URLs (run the gate with a MinIO-backed pool once and record it). - Step 5: Run the tests and the storage-only compat leg against MinIO. Expected: PASS.
- Step 6: Owner-run step:
openxet config check --probe --jsonagainst real AWS S3, R2, Garage (self-hosted), GCS and Azure; record each store's outcome, the failed check if any, the date and the region indocs/reference/object-stores.md, and tick ADR 0002's "Open, owned by M3" items with a link. Until a store is recorded, the page says "not yet probed" for it. - Step 7: Commit as
feat(storage): range-bound presigned downloads gated by the ADR 0002 probe.
Task 8: Serve on production backends, with replica reads
Files:
- Create:
crates/index/src/replica.rs,crates/openxet/tests/serve_backends.rs,docs/guides/production-backends.md - Modify:
crates/openxet/src/{serve,config,config_cmd,purge_cmd,label_cmd}.rs,crates/index/src/postgres.rs(read pool),docs/reference/config.md([index],[storage]),.github/workflows/ci.yml(compat-storage-onlyandcompat-hubalso run on Postgres + MinIO)
Interfaces:
-
Consumes: Tasks 3 to 7.
-
Produces:
openxet servewith[index] url, max_connections, read_url, migrateand[storage] url, [storage.options];purgeand the other data-directory commands open the configured backends through oneopen_backends(&Config) -> (Arc<dyn HubIndex>, Arc<dyn Bucket>, Option<Arc<dyn RangePresigner>>). -
Step 1: Write the failing tests:
serve_on_postgres_and_minio_round_trips(upload, register, read through twoserveprocesses sharing the backends);replica_miss_reads_primary(a replica fixture that lags by holding reads at a snapshot taken before the write: the read still finds the file) andreplica_never_used_for_tokens_or_refs;- config:
remote_store_requires_explicit_keys(M1-R29: a remotestorage.urlwith a defaulted transfer key or CAS-token key is a problem naming both keys),storage_secrets_only_from_files(storage.options.secret_access_keyis refused with a hint to usesecret_access_key_fileor the environment),index_url_bounds.
-
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the wiring and the replica wrapper (
ReplicaIndex<I>delegating writes and authorization reads to the primary, re-reading "absent" from it). -
Step 4: Write
docs/guides/production-backends.md: Postgres, S3, GCS, Azure, MinIO and Garage configs that run in the docs leg against the emulators, and whatconfig check --probesays. -
Step 5: Run the tests, the storage-only and Hub legs on both backends, and the docs leg. Expected: PASS.
-
Step 6: Commit as
feat(server): serve on Postgres and object stores, with replica reads.
Task 9: Index round trips outside the CPU permits (issue #48)
Files:
- Modify:
crates/cas/src/{shards,reconstruction,plan,lib}.rs(index_iosemaphore),crates/cas/tests/{shards,reconstruction}.rs,docs/reference/benchmarks.md
Interfaces:
-
Consumes: Task 8's Postgres-backed state;
MemoryBudget(Task 15 adds shares; this task only reserves). -
Produces:
AppState::index_io: Arc<Semaphore>; the M3-R14 order: reserve,index_io, load, thenvalidate. -
Step 1: Write the failing tests:
chunk_tables_are_reserved_before_loading(the reservation exists beforechunk_tablesis called; a budget too small for the tables gives503 memory_busywithout loading),cpu_permits_idle_during_index_round_trips_is_zero(an index fake that sleeps 50 ms per load: while it sleeps, everyvalidatepermit is free), and deletechunk_tables_are_loaded_under_a_validation_permitin the same commit, citing M3-R14. -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement the reordering in shard ingest and reconstruction.
-
Step 4: Measure shard-ingest throughput and reconstruction p99 against Postgres before and after (
commit_throughputand the latency gate withOPENXET_BENCH_POSTGRES_URL), and record both inbenchmarks.md. -
Step 5: Commit as
perf(cas): load chunk tables and layouts outside the CPU permits (#48).
Task 10: Per-key alg binding, transfer verify keys and the key report (issue #65, code half)
Files:
- Modify:
crates/auth/src/{jwt,signed_url,capability}.rs,crates/auth/tests/auth.rs,crates/openxet/src/{config,config_cmd}.rs,docs/reference/config.md(Keys and tokens on disk, CAS tokens, Transfer URLs)
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
pub enum KeyAlg { EdDSA } // ML-DSA-65 arrives by its own ADR (ADR 0011)
impl PublicKey { pub fn alg(&self) -> KeyAlg }
impl UrlSigner { pub fn with_verify_keys(self, keys: Vec<[u8; 32]>) -> Self } // accepted, never used to sign
impl CapabilitySigner { pub fn with_verify_keys(self, keys: Vec<[u8; 32]>) -> Self }
}
-
config check --jsongainskeys: [{kind, id, alg, age_days, warning?}]. -
Step 1: Write the failing tests:
token_alg_must_match_key_alg,unknown_kid_refused,old_and_new_cas_keys_both_verify,transfer_url_signed_with_previous_key_verifies,capability_signed_with_previous_key_verifies,verify_only_key_never_signs,config_check_warns_on_old_key. -
Step 2: Run. Expected: FAIL (the first three may pass already; keep them as the rotation regression tests).
-
Step 3: Implement
cas.transfer_verify_key_filesand the key report (age from the key file's modification time). -
Step 4: Run the auth, CAS and Hub suites. Expected: PASS.
-
Step 5: Commit as
feat(auth): per-key alg binding, transfer verify keys and a key report (#65).
Task 11: OIDC authentication
Files:
- Create:
crates/auth/src/oidc.rs,crates/auth/tests/oidc.rs,crates/auth/tests/support/issuer.rs(an in-process issuer serving discovery and JWKS, signing RS256, ES256 and EdDSA) - Modify:
crates/auth/src/{lib,error}.rs(authenticatebecomesasync;AuthError::Unavailable), every call site incrates/cas/src/auth.rsandcrates/hub/src/authz.rs,crates/openxet/src/{config,serve}.rs([[auth.oidc]]),crates/cas/tests/andcrates/hub/tests/security matrices,docs/reference/config.md(OIDC)
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
pub struct OidcIssuer { /* name, issuer, audiences, claim mapping, ArcSwap<Jwks> */ }
impl OidcIssuer {
pub async fn discover(cfg: OidcConfig, http: reqwest::Client) -> Result<Self, AuthError>;
pub async fn verify(&self, jwt: &str, now: u64) -> Result<UserPrincipal, AuthError>; // subject "{name}:{sub}"
}
impl Authenticator {
pub fn with_oidc(self, issuers: Vec<Arc<OidcIssuer>>) -> Self;
pub async fn authenticate(&self, bearer: &str, now: u64) -> Result<Credential, AuthError>; // static, CAS JWT, OIDC; access tokens join in Task 13
}
}
- Step 1: Write the failing tests:
oidc_token_becomes_user_principal(subject, name, email and mapped claims),wrong_issuer_or_audience_refused,alg_none_and_hs256_refused,alg_must_match_jwks_key,exp_exact_nbf_iat_skew,jwks_unknown_kid_refreshes_once,jwks_refresh_rate_limited,jwks_outage_serves_cached_then_fails_closed,two_issuers_never_share_a_subject, and in the CAS and Hub matricesoidc_token_scoped_like_static_user_token(rules decide; an OIDC token is never a CAS credential) andoidc_token_never_logged. - Step 2: Run. Expected: FAIL.
- Step 3: Implement with
jsonwebtoken11 onaws-lc-rs; a background task per issuer refreshes JWKS; the unknown-kidpath awaits one refresh under a per-issuer mutex. - Step 4: Compat:
test_hf_whoami_with_oidc_token(the hub leg starts the issuer fromcompat/hub_harness.py;hf auth whoamishows the mapped name and orgs). - Step 5: Run every suite and the hub leg. Expected: PASS.
- Step 6: Commit as
feat(auth): OIDC authentication with cached, fail-closed JWKS.
Task 12: The webhook authorizer
Files:
- Create:
crates/auth/src/webhook.rs,crates/auth/tests/webhook.rs,docs/reference/authorizer-webhook.md(the contract, with a 20-line reference receiver in Python that the docs leg runs) - Modify:
crates/auth/src/{rules,lib}.rs(Eithercomposition),crates/openxet/src/{config,serve}.rs([auth.webhook],auth.authorizer),docs/reference/config.md,docs/SUMMARY.md
Interfaces:
- Produces:
#![allow(unused)]
fn main() {
pub struct WebhookAuthorizer { /* url, secret, timeout, ttls, a bounded LRU of 100k decisions */ }
impl Authorizer for WebhookAuthorizer { .. }
pub struct RulesThenWebhook(pub RulesAuthorizer, pub WebhookAuthorizer);
impl Authorizer for RulesThenWebhook { .. }
}
- Step 1: Write the failing tests:
webhook_allow_and_deny,request_is_signed_and_timestamped,allow_and_deny_cached_per_ttl,cache_keyed_by_subject_and_claims(two subjects, one target: two calls; requirement 3),timeout_fails_closed,non_2xx_or_bad_body_fails_closed,rule_grant_skips_webhook,webhook_secret_never_logged. - Step 2: Run. Expected: FAIL.
- Step 3: Implement; the cache is a bounded LRU (no new
dependency unless
cargo searchfinds one already in the lock file). - Step 4: Run. Expected: PASS; the token, head and resolve p99 < 10 ms gate excludes the webhook (§5.8), so measure a cached decision and record it.
- Step 5: Commit as
feat(auth): the webhook authorizer with caller-keyed, fail-closed caching.
Task 13: OpenXet access tokens
Files:
- Create:
crates/auth/src/access.rs,crates/index/src/tokens.rs(trait),crates/index/src/sqlite/tokens.rs,crates/index/src/postgres/tokens.rs,crates/index/migrations/0009_tokens.sql,crates/index/migrations-postgres/0003_tokens.sql,crates/hub/src/tokens_api.rs,crates/hub/tests/access_tokens.rs,crates/openxet/tests/token_api.rs,docs/guides/identity.md - Modify:
crates/storage/src/records.rs(TokenRecord,TokenRevocation, keystokens/andtoken-revocations/),crates/index/src/conformance/(token cases),crates/openxet/src/token_cmd.rs(issue,list,revokeagainst a server),crates/openxet/src/config.rs(auth.admins,auth.access_token_max_ttl),docs/reference/openapi.json,compat/tests/test_agent_cli.py,compat/tests/test_hub.py,docs/SUMMARY.md
Interfaces:
- Consumes: Task 11's async
authenticate; Task 12's authorizers; Task 4's backends. - Produces:
#![allow(unused)]
fn main() {
pub struct TokenId(Uuid); // UUIDv7, Display = 32 hex
pub enum TokenKind { Access, Delegated(Delegation) } // Delegation filled in by Task 14
pub struct IssuedToken { pub id: TokenId, pub secret_sha256: [u8; 32], pub subject: String, pub claims: BTreeMap<String, Vec<String>>, pub issuer: String, pub kind: TokenKind, pub created_at: u64, pub expires_at: u64 }
#[async_trait] pub trait Tokens {
async fn insert_token(&self, t: &IssuedToken) -> Result<(), IndexError>;
async fn token_by_secret(&self, sha256: &[u8; 32]) -> Result<Option<(IssuedToken, Option<u64> /*revoked_at*/)>, IndexError>;
async fn tokens_of(&self, subject: &str, cursor: Option<&str>, limit: usize) -> Result<Page<IssuedToken>, IndexError>;
async fn revoke_token(&self, id: TokenId, at: u64) -> Result<bool, IndexError>;
async fn touch_token(&self, id: TokenId, at: u64) -> Result<(), IndexError>; // last_used_at, at most once a minute per node
}
// HubIndex gains Tokens.
}
-
Routes:
POST /api/openxet/v1/tokens(OIDC credential only) →{schema: "openxet.token.v1", id, token, expires_at}, the secret shown once;GET /api/openxet/v1/tokens(own tokens, no secrets);DELETE /api/openxet/v1/tokens/{id}(own tokens; admins any);POST /api/openxet/v1/admin/tokens(admins). -
CLI:
openxet token issue|list|revoke --server URLwith the credential fromOPENXET_TOKEN(never a flag, so it stays out of shell history),--json,--dry-runonissueandrevoke. -
Step 1: Write the failing tests:
access_token_issued_from_oidc_only(an access or static token gets403 needs_identity_providerwith a hint);secret_stored_only_as_hash(index row and bucket record hold no secret),token_record_reaches_bucket_before_index(a failing index insert leaves the record and answers503; a retry succeeds);revoked_token_refused_on_every_node(twoserveprocesses; revocation on one, refusal on the other within the 5 s cache),expired_access_token_refused,unknown_secret_not_cached;authorizer_reasked_on_use(the webhook flips to deny: the next request with the token is refused);whoami_answers_bound_identity;- security matrices:
access_token_scoped_to_repo,access_token_never_logged_or_echoed; - compat:
test_hf_login_with_access_token_upload_download(OIDC token from the test issuer →openxet token issue→hf auth login --token→hf upload→hf download).
-
Step 2: Run. Expected: FAIL.
-
Step 3: Implement; every issue and revoke writes an
audit_logrow (actor,token.issue/token.revoke, token id). -
Step 4: Write
docs/guides/identity.md: OIDC (Keycloak, Entra, Google, GitHub Actions examples), the webhook, issuing a token and logging in withhf, the agent invocation first and the human walkthrough beside it (ADR 0014). -
Step 5: Run every suite, the agent and hub legs, and the security-map checks. Expected: PASS.
-
Step 6: Commit as
feat(auth): OpenXet access tokens bound to an OIDC identity.
Task 14: Delegated, attenuated agent tokens
Files:
- Create:
crates/auth/src/delegation.rs,crates/cas/src/usage.rs,crates/hub/tests/delegated_tokens.rs,crates/cas/tests/delegated_usage.rs,docs/guides/agent-tokens.md - Modify:
crates/auth/src/access.rs,crates/index/src/{tokens,sqlite/tokens,postgres/tokens}.rs(usage counters,add_usage), migrations (usage columns: expand),crates/hub/src/{tokens_api,tokens}.rs(CAS tokensub),crates/cas/src/{auth,xorbs,shards,reconstruction}.rs(charge usage),crates/openxet/src/token_cmd.rs(delegate),compat/tests/test_agent_cli.py,docs/SUMMARY.md
Interfaces:
- Consumes: Task 13's
Tokens,IssuedToken, routes and CLI. - Produces:
#![allow(unused)]
fn main() {
pub struct Delegation { pub parent: Option<TokenId> /* None when minted with an OIDC token */, pub on_behalf_of: String, pub repos: Vec<RepoName>, pub mode: Mode, pub max_bytes: Option<u64>, pub max_ops: Option<u64>, pub name: String }
pub struct DelegatedAuthorizer<A: Authorizer>(pub A); // M3-R10's use-time rule over the parent's authorizer
pub struct UsageBatcher { /* per node, flushes every 1 s */ }
impl UsageBatcher { pub fn charge(&self, token: TokenId, ops: u64, bytes: u64); pub fn exhausted(&self, token: TokenId) -> bool /* cache ≤ 2 s */ }
// #[async_trait] Tokens gains: async fn add_usage(&self, batch: &[(TokenId, u64, u64)]) -> Result<Vec<(TokenId, Usage)>, IndexError>;
pub const DELEGATED_SUBJECT_PREFIX: &str = "delegated:";
}
-
Route
POST /api/openxet/v1/tokens/delegate; CLIopenxet token delegate --repo ns/name … --mode read|write --ttl 8h [--max-bytes 10GiB] [--max-ops N] --name NAME. -
Step 1: Write the failing tests (security tests go in the map under requirement 2 and ADR 0014 item 3):
delegated_token_limited_to_listed_repos_and_mode;delegated_token_cannot_widen_itself(no route or CLI accepts a delegated token fordelegate,issue, label changes outside its repos, orcreate);delegated_token_cannot_mint_tokens;revoking_parent_revokes_delegated(revoking the access token a delegated token was minted with refuses the delegated token too);delegated_mint_requires_parent_access_nowandparent_losing_access_revokes_use;delegated_ttl_bounded_and_cas_tokens_end_with_it;byte_cap_counts_upload_and_granted_bytes,op_cap_counts_cas_token_requests,exhausted_within_two_seconds_on_every_node(twoserveprocesses);revocation_stops_outstanding_cas_tokens;reindexed_capped_token_comes_back_exhausted(index rebuilt from the bucket's token records);static_token_cannot_use_delegated_subject_prefix(config problem);- compat (agent leg):
test_agent_delegated_upload_download(a human OIDC identity delegates write on one repo; anhf uploadwith the delegated token works there and is refused on another repo, with the hint).
-
Step 2: Run. Expected: FAIL.
-
Step 3: Implement; audit rows attribute actions as
delegated:{id}on behalf of the parent subject (the M4 audit API reads them); the mint answer shows the token once with copy-readyhfandopenxetcommands. -
Step 4: Write
docs/guides/agent-tokens.md(human first, as a control-surface guide per ADR 0014): mint, hand to an agent, watch usage withtoken list --json, revoke; the caps' two-second overshoot and the granted-bytes caveat in plain words. -
Step 5: Run every suite, the agent leg and the map checks. Expected: PASS.
-
Step 6: Commit as
feat(auth): delegated, attenuated agent tokens with byte and operation caps.
Task 15: Rate limits, in-flight limits and memory shares (issues #57, M1-R40)
Files:
- Create:
crates/http/src/limits.rs,crates/http/tests/limits.rs,crates/cas/tests/memory_shares.rs,compat/tests/test_limits.py - Modify:
crates/http/src/{lib,middleware}.rs,crates/cas/src/{budget,auth,reconstruction}.rs,crates/hub/src/authz.rs,crates/openxet/src/config.rs([limits],server.credential_memory_share,server.tenant_memory_share),docs/reference/config.md(Limits; the I-2 fairness sentence #57 asks for),docs/reference/exit-codes.md(429)
Interfaces:
- Consumes: the resolved
Credentialfrom Tasks 11 and 13; the per-tenant overrides Task 16 adds (this task reads them through a trait with a config-only implementation). - Produces:
#![allow(unused)]
fn main() {
pub struct LimitKey { pub tenant: String, pub credential: [u8; 32] /* SHA-256 of the credential's id */ }
pub trait TenantLimits: Send + Sync { fn limits_for(&self, tenant: &str) -> RateLimits; }
pub struct Limiter { .. }
impl Limiter {
pub fn check(&self, key: &LimitKey) -> Result<InFlight, Limited>; // InFlight drops to release; Limited → 429 rate_limited, Retry-After
pub fn auth_failure(&self, ip: IpAddr) -> Result<(), Limited>;
}
impl MemoryBudget {
pub async fn reserve_for(&self, who: &LimitKey, bytes: u64, kind: ReserveKind) -> Result<Reservation, CasError>; // shares per M3-R13
}
}
- Step 1: Write the failing tests:
over_rate_answers_429_with_retry_after,tenants_do_not_share_buckets,in_flight_limit_releases_on_drop,probes_exempt,auth_failures_limited_per_ip;credential_share_caps_one_reader(one credential's answers stop at a quarter of the budget while another credential still reserves),tenant_share_caps_many_credentials;- #57:
ranged_reconstruction_reserves_only_its_range,reconstruction_reserves_once,answer_too_large_never_for_a_fitting_range; - compat:
test_default_limits_never_throttle_stock_client(4 GiB up and down with defaults, the server log shows no429),test_rate_limited_client_recovers(limits set low:hf_xetretries and completes).
- Step 2: Run. Expected: FAIL.
- Step 3: Implement; if the first compat test shows a default throttling a stock client, change the default and M3-R11 together, with the measured request rate.
- Step 4: Run every suite,
measure-memory-bounds.pywith two credentials (record peaks inbenchmarks.md), and the compat test. Expected: PASS. - Step 5: Commit as
feat(server): per-tenant and per-credential rate limits and memory shares (#57).
Task 16: Per-tenant quotas and the tenant-limits admin API
Files:
- Create:
crates/index/src/limits.rs(trait),crates/index/src/{sqlite,postgres}/limits.rs, migrations (tenant_usage,tenant_limits: expand),crates/hub/src/admin.rs,crates/hub/tests/admin_limits.rs,crates/cas/tests/quotas.rs - Modify:
crates/storage/src/records.rs(TenantLimitsRecord,tenant-limits/),crates/index/src/{sqlite,postgres}.rs(stored-bytes counter in the owner insert),crates/index/src/rebuild.rs(rebuild counters from owner records),crates/cas/src/xorbs.rs(quota check before reading the body),crates/http/src/limits.rs(an index-backedTenantLimits),docs/reference/config.md,docs/reference/openapi.json
Interfaces:
- Consumes: Task 13's admins, Task 15's
TenantLimits. - Produces:
#![allow(unused)]
fn main() {
pub struct TenantUsage { pub stored_bytes: u64 }
pub struct TenantLimitsRow { pub quota_bytes: Option<u64>, pub rates: Option<RateLimits> }
#[async_trait] pub trait Limits {
async fn tenant_usage(&self, tenant: &str) -> Result<TenantUsage, IndexError>;
async fn tenant_limits(&self, tenant: &str) -> Result<Option<TenantLimitsRow>, IndexError>;
async fn set_tenant_limits(&self, tenant: &str, row: &TenantLimitsRow) -> Result<(), IndexError>;
}
}
-
PUT|GET /api/openxet/v1/admin/tenants/{tenant}/limits. -
Step 1: Write the failing tests:
quota_refuses_xorb_before_body_read(403 quota_exceededwith quota, usage and hint; no bytes read),quota_counts_owned_xorbs_alone(a xorb owned by two tenants counts for each),dedup_hit_uses_no_quota(a shard-only upload passes at quota),reads_never_stop_at_quota,stored_bytes_rebuilt_from_owner_records,tenant_limits_record_reaches_bucket_first,admin_limits_require_admin,limits_override_reaches_every_node_within_10s; compat:test_quota_error_renders_in_hf_cli(the message and hint reach the user; confirm with the stock client that403fails fast rather than retrying, and record it in M3-R12). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement.
-
Step 4: Run every suite and the map checks. Expected: PASS.
-
Step 5: Commit as
feat(server): per-tenant storage quotas and the tenant-limits admin API.
Task 17: Bound one writer's influence on dedup answers (issue #50)
Files:
- Modify:
crates/index/src/lib.rs(dedup_shard_xorbstakes the caller's tenant),crates/index/src/{sqlite,postgres}.rs,crates/index/src/conformance/cas.rs,crates/cas/src/dedup.rs,crates/cas/tests/dedup.rs
Interfaces:
-
Produces:
async fn dedup_shard_xorbs(&self, xorb: &MerkleHash, caller_tenant: &str, visibility: Visibility, budget: u64) -> Result<…, IndexError>with M3-R17's preference. -
Step 1: Write the failing tests:
global_prefers_callers_own_shard(tenant A registers shard S1, tenant B later registers S2 over the same xorb: A's answer is built from S1),global_falls_back_to_newest_any_tenant,per_tenant_unchanged(the M1 per-tenant tests still pass unedited). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement in both backends.
-
Step 4: Run the index and CAS suites. Expected: PASS.
-
Step 5: Commit as
feat(cas): prefer the caller's own shards in global dedup answers (#50).
Task 18: Zero-copy answer segments and streaming xorb validation (issue #54)
Files:
- Modify:
crates/cas/src/{answer,send_body}.rs,crates/cas/tests/reconstruction.rs; if step 1 allows,crates/cas/src/xorbs.rs,crates/protocol/src/xorb.rs,crates/storage/src/lib.rs(astaging/key kind never listed or served)
Interfaces:
-
Produces: answers written as independently allocated segments of at most 1 MiB, sent without copying (the #54 comment's proposal); if upstream allows,
openxet_protocol::XorbValidatorthat takes chunks as they arrive and yields the verified chunk table at the end. -
Step 1: Check upstream (
xet-core-structures=1.7.0): can a xorb be validated chunk by chunk with its own types (R29: no format logic of our own)? Record the answer, with the file and line, in the PR. If not, open an issue upstream (or reuse one), keep streaming validation out of this task, and leave #54 open for that half only. -
Step 2: Write the failing tests:
answer_segments_at_most_1mib,stalled_client_pins_at_most_one_segment_past_deadline(replaces the copy-based test at segment granularity), and, if step 1 allows,streamed_xorb_validation_peak_is_one_chunk,failed_streamed_xorb_leaves_nothing_readable. -
Step 3: Run. Expected: FAIL.
-
Step 4: Implement; staged bytes are never readable and never count for dedup or verification (design §5.5's grant rule).
-
Step 5: Measure concurrent clients per GiB before and after with
measure-memory-bounds.py, and the latency gate's p99, inbenchmarks.md. -
Step 6: Commit as
perf(cas): zero-copy answer segments (#54), plusperf(cas): validate xorbs while streaming (#54)if step 1 allows.
Task 19: The load harness, generator and single-node scenarios (issue #55)
Files:
- Create:
crates/loadgen/{Cargo.toml,src/main.rs,src/scenario.rs,src/ops.rs,src/histogram.rs,src/report.rs},loadtest/{README.md,pyproject.toml,uv.lock,run.py,thresholds.toml},loadtest/scenarios/{steady_mix,spike,thundering_herd,slow_clients,dedup_heavy,dedup_free,soak}.toml,loadtest/tests/test_run.py,docs/reference/load-testing.md - Modify:
Cargo.toml(member),deny.tomlif needed,docs/SUMMARY.md,AGENTS.md(Commands)
Interfaces:
-
Consumes: Task 8's
serveon production backends. -
Produces:
openxet-loadgen run --scenario FILE --target URL --token-file F --json→openxet.loadtest.v1{revision, machine, config, scenario, ops: {name: {count, errors: {code: n}, p50, p99, p999, histogram}}, memory: {peak, budget}, throughput}.uv run --directory loadtest python run.py SCENARIO [--nodes N] [--backend sqlite|postgres] [--duration D]starts the stack, runsopenxet-loadgenand stockhf_xetclients as the scenario says, collects server memory from/metricsor the process, and checksthresholds.toml, exiting non-zero on a breach.- Scenario keys:
duration,ops(weights overreconstruct,upload_xorb,upload_shard,dedup_query,head),rate(requests per second, or a ramp),clients(native orhf_xetprocesses),tenants,file_mix,dedup_ratio.
-
Step 1: Write the failing tests:
loadgenunit tests (scenario_parses_and_validates,histogram_merges_exactly,generated_xorbs_verify,dedup_ratio_is_honoured) andloadtest/tests/test_run.py::test_thresholds_fail_the_run(a threshold set to 0 ms fails a 10 s run with exit code 1 and names the breach). -
Step 2: Run. Expected: FAIL.
-
Step 3: Implement; the steady mix uses §5.8's rates (reconstruction 1,000/s, 2,000 xorb and 200 shard registrations/s at full scale; the scenario scales them by a
scalefactor so the nightly runner can run it); the spike is 10× for 60 s; the thundering herd is 500 clients pulling one new file; slow clients read at 64 KiB/s and hold idle connections up toserver.max_connections. -
Step 4: Run each scenario for 60 s on one node locally and record the method in
docs/reference/load-testing.md. -
Step 5: Commit as
feat(loadtest): native generator, scenarios and threshold checks (#55).
Task 20: Multi-node, the node kill, the soak and the reconstruction p99 under load (issues #55, #43)
Files:
- Create:
loadtest/compose.yaml(Postgres 17, MinIO, Nopenxetnodes from one image, HAProxy 3 round robin),loadtest/haproxy.cfg,loadtest/drills/{kill_node,rolling_upgrade}.py,loadtest/scenarios/reconstruct_p99.toml,.github/workflows/loadtest-nightly.yml - Modify:
loadtest/run.py(--nodes),docs/reference/benchmarks.md
Interfaces:
-
Consumes: Task 19's runner, Task 7's direct downloads, Task 5's migration policy.
-
Produces:
run.py --nodes N, thekill_nodeandrolling_upgradedrills, and the nightly workflow. -
Step 1: Write the failing checks:
kill_node: during the steady mix,SIGKILLone node; every write the generator saw acknowledged is then readable through each surviving node, and errors are limited to requests in flight on the killed node (requirement 7, non-negotiables 2 and 5);rolling_upgrade: nodes on the merge-base build and on the head build share one database through a node-by-node upgrade under load, with zero errors outside the documented ones (M3-R6);reconstruct_p99: 1,000 reconstructions/s against an index of 10^7 xorbs (seeded by the generator through the CAS, not by SQL) with concurrent uploads; p99 < 50 ms (#43).
-
Step 2: Run on 3 nodes in compose. Expected: the drills fail until the compose stack and drills exist; then record where p99 stands. If
reconstruct_p99misses, profile and either speed up the single-thread render or state the capacity per node it holds at, inbenchmarks.mdand #43, before closing it. -
Step 3: Nightly workflow: builds the image, runs the steady mix, spike, herd, slow clients, dedup scenarios,
kill_node,rolling_upgradeand a 30 min soak at the nightly scale, uploads the JSON results as artifacts, and fails per M3-R16. -
Step 4: Owner-run step: the release rig (N ≥ 4 node hosts): scaling 1, 2, 4 and N nodes on the steady mix (near-linear means at least 0.8 × N of one node's throughput at N), the 24 h soak (flat RSS, no leaked permits or file descriptors), and
reconstruct_p99at full scale; results with machine, method and raw data inbenchmarks.md. -
Step 5: Commit as
feat(loadtest): multi-node rig, node kill, rolling upgrade, soak and the p99 under load (#55, #43).
Task 21: Many tenants, one noisy, and abuse load
Files:
- Create:
loadtest/scenarios/{many_tenants,abuse}.toml - Modify:
loadtest/run.py(per-tenant reporting),loadtest/thresholds.toml,docs/reference/benchmarks.md
Interfaces:
-
Consumes: Tasks 15 and 16 (limits, shares, quotas), Task 20's rig.
-
Step 1: Write the checks:
many_tenants: 50 tenants at the steady mix and one at 20× its rate limit: the quiet tenants' p99 stays within 20 % of the run without the noisy one, the noisy one gets429s only;abuse: one credential opening 4× its in-flight limit, slow-reading answers and retrying503s instantly: the memory budget holds, other credentials keep their share, and quota-exceeding uploads are refused before their bodies are read (#57's fairness sentence is observed here). -
Step 2: Run on 3 nodes and record the numbers.
-
Step 3: Add both to the nightly at its scale.
-
Step 4: Commit as
test(loadtest): noisy-tenant and abuse scenarios verify limits, shares and quotas.
Task 22: The key-rotation drill under load (issue #65)
Files:
- Create:
loadtest/drills/key_rotation.py,docs/guides/key-rotation.md - Modify:
.github/workflows/loadtest-nightly.yml,docs/SUMMARY.md,docs/reference/config.md(links)
Interfaces:
-
Consumes: Task 10's verify keys and key report; Task 20's rig.
-
Step 1: Write the drill: while the steady mix runs on 3 nodes, rotate the CAS-token key, then the transfer key, by M3-R15's steps, each step a rolling restart. Checks: zero failed requests caused by the rotation; tokens and URLs signed with the old key work until their
exp; new ones work once published; an unknownkidand a mismatchedalgare refused; the old key is refused once removed;config checkreports the key set at each step. -
Step 2: Write
docs/guides/key-rotation.mdfrom the drill's steps, for operators and for platforms that sign their own tokens; the docs leg runs its commands against a single node. -
Step 3: Run the drill locally and in the nightly. Expected: PASS.
-
Step 4: Commit as
test(loadtest): CAS-token and transfer key rotation drill under load (#65).
Task 23: Release gate, published results, docs and project state
Files:
-
Modify:
docs/reference/benchmarks.md(every M3 number with machine, method and raw data links),docs/reference/security-tests.md(no M3 row pending),docs/superpowers/specs/2026-10-23-openxet-design.md(§12 M3 marked done, PQ TLS noted as shipped in #89),docs/README.md,docs/SUMMARY.md,README.md,CHANGELOG.md,AGENTS.md(Current state: M3 complete, next per ADR 0013; Commands: Postgres and emulator variables,loadtestcommands),website/index.htmlonly if a capability claim changes (perdesign.md) -
Step 1: Release gate: document the release-rig run as a release checklist item in
GOVERNANCE.md's release section (or the release doc it points at) with the exact command, and the thresholds file as the gate. -
Step 2: Run
scripts/build-site.sh, every compat leg, the map checks,scripts/check-openapi.sh,check-copy.pyandcheck-brand.py. Expected: PASS. -
Step 3: Final review of the whole M3 branch set by a fresh reviewer against design §12 M3, §5.9, ADR 0002's open list and this plan's Review Focus.
-
Step 4: Close or update #43, #47, #48, #50, #54, #55, #57 and #65 with links to the merged work and the published numbers.
-
Step 5: Commit as
docs: production backends, identity and agent-token guides, M3 results and state.
Out of M3, by design
Assigned to later milestones by the design, not deferred for convenience:
- M4:
openxet gc(it sweepsprobe/andstaging/leftovers),openxet reindexas a command (it restores token, revocation and tenant-limit records too), readiness that checks the index and the bucket (#49), metrics beyond the gauges named here, metering and its admin API (built on Task 16's counters), the audit API over the rows Tasks 13 and 14 write. - M5: buckets as delegated-token targets (
bucketsin the delegate body is rejected with a hint until then). - M6:
openxet login(the OIDC device and browser flow), the web UI for delegated tokens, MCP tools for token minting, approval gates, budgets and alerts. - M8: the stand-alone billing ADR, capacity tests against a production-size staging stack with this harness.
Decisions needing the owner
- #50 policy (M3-R17). Own-tenant preference is planned; the alternative is ranking shards by how many tenants reference them. Task 17 builds whichever the owner picks; both fit the same index call.
- Cloud accounts. Tasks 6, 7 and 20 have owner-run steps that need an AWS account, a Cloudflare R2 token, a GCS project, an Azure storage account, and a release rig of at least four node hosts plus a Postgres host. CI covers everything else with emulators.
- Default limits (M3-R11, M3-R13). The planned defaults are generous for self-hosters; openxet.com's free tier will want lower per-tenant values, set through the admin API, not new defaults.
- Access-token lifetime. 90 days by default and at most 365 days (M3-R9); the owner may prefer shorter for openxet.com.