Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/CLOUD_ACCEPTANCE_READINESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Cloud acceptance readiness — milestone 1

The owner approved the seven delivery milestones on 29 September 2026. This
change implements the first runtime prerequisite, not all seven milestones.

`GET /readyz` is an operator-only, effect-free endpoint, authenticated with the
existing WORKER_TICK_TOKEN. A configuration request proves exact deployed
version, source tag and required binding presence without querying the database.
A database request checks only the three existing metadata RPCs and their
required capabilities. No scheduler, tenant record, model, provider, job claim
or completion path is invoked. A readiness response never authorises execution.

The caller supplies X-OCPF-Expected-Version (UUID), X-OCPF-Expected-Sha (40 hex),
X-OCPF-Readiness-Nonce (32–64 hex), and X-OCPF-Readiness-Mode (configuration or
database). Responses are non-cacheable, echo the challenge and identify the
actual version. Failed authentication stays opaque. Errors contain fixed codes,
not credentials, origins, database responses or tenant data. Only hosted
Supabase origins, or a temporary tunnel in explicit staging, are admitted.

This addresses the previous acceptance design defect: `/healthz` liveness was
followed immediately by a business tick before authentication, version and
backend readiness were distinguished. It is not yet proof of the cause of every
previous HTTP 404/500, or evidence that hosted acceptance has passed.

The app-owned harness must verify the active deployment and exact version,
perform bounded configuration/database probes, then execute each acceptance
job once. Only effect-free probes may be retried. An ambiguous job/tick failure
must stop the experiment. Preserve every trial, including failures and cleanup.

Production controls, cron configuration, provider capability gates and existing
ledgers are unchanged. No new database branch, subscription, image service or
always-running compute is required. Cloud execution still needs capacity/budget
verification and independent teardown. The app repository retains private
schema source; do not copy it into this public repository for CI savings.

Local validation on the operator's isolated coding environment: TypeScript
check and 63 synthetic readiness tests passed. Hosted/full current-head CI is a
separate result and must be read before promotion. No production deployment was
performed by this source change.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "node --import tsx scripts/build.ts",
"typecheck": "tsc --noEmit --project tsconfig.json",
"test": "npm run build && node dist/test/security-hardening.test.js && node dist/test/source-ssrf.test.js && node dist/test/browser-collector-ingest.test.js && node dist/test/slot-scheduler.test.js && node dist/test/daily-inventory-planner.test.js && node dist/test/refresh-queue-finalization.test.js && node dist/test/publish-summary.test.js && node dist/test/threads-refresh.test.js && node dist/test/linkedin-refresh.test.js && node dist/test/instagram-image-timeout.test.js && node dist/test/cost-control.test.js && node dist/test/recovery-scheduler.test.js && node dist/test/social-connector.test.js && node dist/test/meta-publication-boundary.test.js && node dist/test/cloudflare-health.test.js && node dist/test/canary-policy.test.js && node dist/test/exclusive-run-gate.test.js && node dist/test/runtime-scope.test.js && node dist/test/tenant-platform-policy.test.js && node dist/test/supabase-client-retry.test.js && node dist/test/worker-claims.test.js && node dist/test/publication-ledger.test.js && node dist/test/publication-outcome.test.js && node dist/test/publication-executor.test.js && node dist/test/provider-single-dispatch.test.js && node dist/test/publication-receipts.test.js && node dist/test/legacy-revision-hold.test.js && node dist/test/http-cancellation.test.js && node dist/test/scheduler-isolation.test.js && node dist/test/agent-jobs.test.js",
"test": "npm run build && node dist/test/security-hardening.test.js && node dist/test/source-ssrf.test.js && node dist/test/browser-collector-ingest.test.js && node dist/test/slot-scheduler.test.js && node dist/test/daily-inventory-planner.test.js && node dist/test/refresh-queue-finalization.test.js && node dist/test/publish-summary.test.js && node dist/test/threads-refresh.test.js && node dist/test/linkedin-refresh.test.js && node dist/test/instagram-image-timeout.test.js && node dist/test/cost-control.test.js && node dist/test/recovery-scheduler.test.js && node dist/test/social-connector.test.js && node dist/test/meta-publication-boundary.test.js && node dist/test/cloudflare-health.test.js && node dist/test/canary-policy.test.js && node dist/test/exclusive-run-gate.test.js && node dist/test/runtime-scope.test.js && node dist/test/tenant-platform-policy.test.js && node dist/test/supabase-client-retry.test.js && node dist/test/worker-claims.test.js && node dist/test/publication-ledger.test.js && node dist/test/publication-outcome.test.js && node dist/test/publication-executor.test.js && node dist/test/provider-single-dispatch.test.js && node dist/test/publication-receipts.test.js && node dist/test/legacy-revision-hold.test.js && node dist/test/http-cancellation.test.js && node dist/test/scheduler-isolation.test.js && node dist/test/agent-jobs.test.js && node dist/test/cloudflare-readiness.test.js",
"smoke:dist": "node dist/src/cli.js status",
"ci": "npm run typecheck && npm test && npm run smoke:dist",
"dev": "tsx src/agent.ts",
Expand Down
151 changes: 151 additions & 0 deletions src/cloudflare-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Operator-only, effect-free readiness. Never import the scheduler, tenant
// credentials, global config, paid models or provider adapters in this module.
export interface ReadinessEnv {
NODE_ENV?: string;
WORKER_TICK_TOKEN?: string;
SUPABASE_URL?: string;
SUPABASE_SERVICE_ROLE_KEY?: string;
SUPABASE_SECRET_KEY?: string;
SERVICE_ROLE_KEY?: string;
CREDENTIAL_ENCRYPTION_KEY?: string;
CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };
}

const SHA = /^[a-f0-9]{40}$/;
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const NONCE = /^[a-f0-9]{32,64}$/;
const MAX_BYTES = 16384;
const HEADERS = { 'Cache-Control': 'no-store, max-age=0', Vary: 'Authorization' };

export const READINESS_CONTRACTS = [
{ rpc: 'get_worker_schema_contract', contract: 'worker-claims-v1', capabilities: [
'source-targeted-claim-v1', 'angle-targeted-claim-v1', 'angle-exhaust-fenced-v1',
'source-angle-atomic-commit-v1', 'angle-queue-atomic-commit-v1',
'queue-angle-identity-v1', 'legacy-queue-revision-hold-v1',
] },
{ rpc: 'get_publication_schema_contract', contract: 'publication-ledger-v1', capabilities: [
'publication-intent-claim-v1', 'publication-dispatch-boundary-v1',
'publication-attempt-outcome-v1', 'publication-unknown-reconciliation-v1',
'publication-exact-history-receipt-v1', 'publication-queue-compatibility-fence-v1',
'publication-provenance-snapshot-v1', 'publication-legacy-queue-hold-v1',
'publication-queue-lock-order-v1',
] },
{ rpc: 'get_agent_jobs_contract', contract: 'agent-jobs-v1', capabilities: [
'durable-enqueue-v1', 'fenced-terminal-v1', 'reconciliation-only-recovery-v1',
'rpc-only-job-writes-v1',
] },
] as const;

type JsonObject = Record<string, unknown>;
function object(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

async function boundedJson(response: Response): Promise<unknown> {
const declared = response.headers.get('Content-Length');
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_BYTES)) {
await response.body?.cancel();
throw new Error('invalid_response');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('invalid_response');
let size = 0;
const chunks: Uint8Array[] = [];
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_BYTES) throw new Error('invalid_response');
chunks.push(value);
}
const joined = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.length; }
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joined));
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}

// Only fixed, existing metadata RPCs are consulted. POST is their transport
// convention, not permission to run a job. Response data is never passed through.
export async function handleReadinessRequest(
request: Request,
env: ReadinessEnv,
fetchImpl: typeof fetch = globalThis.fetch,
): Promise<Response> {
const token = env.WORKER_TICK_TOKEN;
if (request.method !== 'GET' || !token || request.headers.get('Authorization') !== `Bearer ${token}`) {
return new Response('Not found', { status: 404, headers: HEADERS });
}
const nonce = request.headers.get('X-OCPF-Readiness-Nonce') || '';
const expectedVersion = request.headers.get('X-OCPF-Expected-Version') || '';
const expectedSha = request.headers.get('X-OCPF-Expected-Sha') || '';
const mode = request.headers.get('X-OCPF-Readiness-Mode') || '';
if (!NONCE.test(nonce) || !UUID.test(expectedVersion) || !SHA.test(expectedSha)
|| !['configuration', 'database'].includes(mode)) {
return Response.json({ ok: false, code: 'readiness_request_invalid' }, { status: 400, headers: HEADERS });
}
const metadata = env.CF_VERSION_METADATA;
const version = metadata && UUID.test(metadata.id) ? metadata.id : null;
const sha = metadata?.tag && SHA.test(metadata.tag) ? metadata.tag : null;
const base = {
schema: 'ocpf.readiness.v1', nonce, mode, workerVersionId: version, gitSha: sha,
effectFree: true, authorisesExecution: false,
};
const fail = (code: string, status = 503) => Response.json(
{ ...base, ok: false, code }, { status, headers: HEADERS },
);
if (version !== expectedVersion || sha !== expectedSha) return fail('release_identity_mismatch', 409);
const serviceKey = env.SUPABASE_SERVICE_ROLE_KEY || env.SUPABASE_SECRET_KEY || env.SERVICE_ROLE_KEY;
if (!serviceKey?.trim() || !env.CREDENTIAL_ENCRYPTION_KEY?.trim()) return fail('runtime_bindings_incomplete');
let origin: URL;
try {
origin = new URL(env.SUPABASE_URL || '');
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.port
|| origin.pathname !== '/' || origin.search || origin.hash) throw new Error('invalid_origin');
const hosted = /^[a-z0-9]{20}\.supabase\.co$/.test(origin.hostname);
const temporary = env.NODE_ENV === 'staging' && /^[a-z0-9-]+\.trycloudflare\.com$/.test(origin.hostname);
if (!hosted && !temporary) throw new Error('invalid_origin');
} catch { return fail('database_origin_invalid'); }
if (mode === 'configuration') {
return Response.json({ ...base, ok: true, code: 'configuration_ready', databaseEvaluated: false },
{ status: 200, headers: HEADERS });
}

const signal = AbortSignal.any([request.signal, AbortSignal.timeout(12000)]);
for (const required of READINESS_CONTRACTS) {
let response: Response;
let body: unknown;
try {
signal.throwIfAborted();
response = await fetchImpl(`${origin.origin}/rest/v1/rpc/${required.rpc}`, {
method: 'POST', redirect: 'error', cache: 'no-store', signal,
headers: { Authorization: `Bearer ${serviceKey}`, apikey: serviceKey, 'Content-Type': 'application/json' },
body: '{}',
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if ([401, 403].includes(response.status)) return fail('database_auth_rejected');
if (response.status === 404) return fail('database_contract_unavailable');
return fail('database_transport_unavailable');
}
body = await boundedJson(response);
signal.throwIfAborted();
} catch { return fail('database_transport_unavailable'); }
if (!object(body) || body.contract !== required.contract || !Array.isArray(body.capabilities)
|| required.capabilities.some(c => !(body.capabilities as unknown[]).includes(c))) {
return fail('database_contract_mismatch');
}
if (required.contract === 'publication-ledger-v1'
&& (body.migration !== '20260907054000' || body.lock_order_migration !== '20260913061000')) {
return fail('database_contract_mismatch');
}
}
return Response.json({
...base, ok: true, code: 'database_ready', databaseEvaluated: true,
contracts: READINESS_CONTRACTS.map(c => c.contract),
}, { status: 200, headers: HEADERS });
}
4 changes: 4 additions & 0 deletions src/cloudflare-worker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { handleReadinessRequest } from './cloudflare-readiness';
import { createExclusiveRunGate } from './exclusive-run-gate';
import { installScopedConfig, runWithRuntimeScope } from './runtime-scope';

Expand Down Expand Up @@ -193,6 +194,9 @@ export default {

async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/readyz') {
return handleReadinessRequest(request, env);
}
if (url.pathname === '/healthz') {
applyCloudflareEnv(env);
return Response.json(healthPayload(env));
Expand Down
Loading
Loading