Skip to content
Merged
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
45 changes: 31 additions & 14 deletions .github/workflows/deploy-cloudflare-worker.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
name: deploy-cloudflare-worker

# Source merges are not deployment authorisation. Keep publication rollout
# manual while the schema-first staging, drain and canary gates are outstanding.
on:
push:
branches:
- main
paths:
- 'config.ts'
- 'content-os/**'
- 'package-lock.json'
- 'package.json'
- 'scripts/**'
- 'src/**'
- 'test/**'
- 'tsconfig.json'
- 'wrangler.toml'
- '.github/workflows/deploy-cloudflare-worker.yml'
workflow_dispatch:
inputs:
expected_sha:
description: 'Full upstream main SHA reviewed for this deployment'
required: true
type: string
rollout_preflight_confirmed:
description: 'Owner confirms schema capability, legacy drain and staging evidence for this SHA'
required: true
type: boolean
default: false

permissions:
contents: read

concurrency:
group: production-worker-deployment
cancel-in-progress: false

jobs:
deploy:
if: github.repository == 'OneClickPostFactory/social-agents' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Require exact release identity and explicit rollout approval
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
RELEASE_SHA: ${{ github.sha }}
ROLLOUT_PREFLIGHT_CONFIRMED: ${{ inputs.rollout_preflight_confirmed }}
run: |
set -eu
test "$ROLLOUT_PREFLIGHT_CONFIRMED" = 'true'
test "$EXPECTED_SHA" = "$RELEASE_SHA"
echo "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{40}$'
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '24'
Expand Down
34 changes: 22 additions & 12 deletions docs/reliability-repair-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file records only implementation status that is evidenced by repository cha

## Sequence 1: release identity and complete CI gate

Status: implemented on `codex/reliability-foundation-1` and proven by upstream pull-request CI on head `a73d1d9202f5013a944b9a2320ebb22be7caf4b1`.
Status: merged to upstream `main` through PR #2. The final pre-merge branch head was proven by the complete upstream pull-request CI gate before merge.

Evidence:

Expand All @@ -16,17 +16,17 @@ Evidence:

## D03 containment: overlapping tenant runtime state

Status: implemented and proven by upstream pull-request CI on head `ee377775030d27117b2568bc10426da8304c7a24`.
Status: merged to upstream `main` through PR #3 after fresh upstream CI passed on the branch synchronised to the PR #2 merge commit.

The Cloudflare scheduled and authenticated `/tick` job-drain entry points share one per-isolate exclusive run gate. This prevents two overlapping SaaS drains in the same Worker isolate from entering the mutable tenant runtime concurrently. Separate Worker isolates do not share process globals.

The containment regression deliberately interleaves two executions, proves maximum concurrent execution is one, and proves a rejected execution releases the gate.

## D03 runtime isolation: process-global config and token callbacks

Status: implemented on `codex/immutable-tenant-runtime`; upstream CI evidence is still required before this layer can be marked proven.
Status: merged to upstream `main` through PR #4. Fresh upstream CI run #88 passed `npm ci` and the complete `npm run ci` gate on synchronised head `f68e4434efc18053d11c85ff1b3d2dd334c5dc3d` before merge. The upstream PR #4 merge commit is `033b9b578c120bec0b725311eba1db3d3bfe5530`.

The Worker now installs async-scoped accessors on the existing config object only after Cloudflare bindings have been copied into `process.env`. Each scheduled/authenticated SaaS drain then runs inside its own `AsyncLocalStorage` context.
The Worker installs async-scoped accessors on the existing config object only after Cloudflare bindings have been copied into `process.env`. Each scheduled/authenticated SaaS drain then runs inside its own `AsyncLocalStorage` context.

Within that context:

Expand All @@ -38,16 +38,26 @@ Within that context:

The current `processPendingSupabaseJobs()` implementation remains serial, so tenant runtime mutation is restored between jobs inside a drain. The earlier exclusive run gate remains defence-in-depth but is no longer the only boundary preventing overlapping Worker invocations from sharing config or token callbacks.

Acceptance evidence required before this layer is considered proven:
The proven regression covers overlapping tenant credentials, scope-local Threads/LinkedIn/X persistence callbacks, rotated-token isolation, base-config isolation and failure cleanup. Explicit provider-client arguments remain desirable architectural cleanup, but the process-global cross-tenant safety defect is no longer the active blocker.

- two deliberately overlapping runtime scopes resolve different OpenAI/provider credentials;
- Threads, LinkedIn and X token rotations invoke only the persistence callback belonging to their own scope;
- rotated credentials remain visible inside the originating scope but do not change the base config or the other scope;
- a failed scoped execution cannot leak its config into the next execution;
- the complete `npm run ci` gate passes on the exact branch SHA.
No provider is re-enabled and no deployment was performed by these D03 repairs.

No provider is re-enabled and no deployment is performed by this refactor.
## D22: fail-closed tenant platform activation

Status: implemented on `codex/fail-closed-platform-settings`; upstream pull-request CI evidence is required before merge.

Tenant platform activation now has one explicit policy: a platform is active only when its persisted `*_enabled` setting is exactly boolean `true`. Missing settings rows, missing fields, `null` and `false` all remain disabled.

The regression covers:

- a missing settings object enabling no platforms;
- all-null flags enabling no platforms;
- all-false flags enabling no platforms;
- mixed settings enabling only explicit `true` entries;
- canonical platform ordering when all five platforms are explicitly enabled.

No provider is re-enabled, no credential semantics change, no queue or billing behaviour changes, and no deployment is performed by this repair.

## Next bounded repair

After runtime isolation is green, make missing tenant platform settings fail closed (D22), then proceed to atomic database claims/fencing and publication-attempt identity. Meta publication remains disabled until the publication ledger and provider-specific restoration work are ready.
After D22 is green and merged, proceed to atomic database claims/fencing for jobs, sources and angles, then publication-attempt identity and unknown-outcome handling. Meta publication remains disabled until the publication ledger and provider-specific restoration work are ready.
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": "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/exclusive-run-gate.test.js && node dist/test/runtime-scope.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/exclusive-run-gate.test.js && node dist/test/runtime-scope.test.js && node dist/test/tenant-platform-policy.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
27 changes: 27 additions & 0 deletions src/platform-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { PlatformKey } from './types';

export interface PlatformEnableSettings {
threads_enabled?: boolean | null;
instagram_enabled?: boolean | null;
linkedin_enabled?: boolean | null;
x_enabled?: boolean | null;
facebook_enabled?: boolean | null;
}

const PLATFORM_SETTINGS: ReadonlyArray<readonly [keyof PlatformEnableSettings, PlatformKey]> = [
['threads_enabled', 'threads'],
['instagram_enabled', 'instagram'],
['linkedin_enabled', 'linkedin'],
['x_enabled', 'x'],
['facebook_enabled', 'facebook'],
];

/**
* SaaS platform activation is fail-closed. Missing, null, and false values are
* disabled; only an explicit persisted boolean true opts a tenant in.
*/
export function activePlatformsFromSettings(settings: PlatformEnableSettings): PlatformKey[] {
return PLATFORM_SETTINGS
.filter(([setting]) => settings[setting] === true)
.map(([, platform]) => platform);
}
8 changes: 2 additions & 6 deletions src/supabase-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as cloudinary from './cloudinary';
import * as instagram from './instagram';
import * as linkedin from './linkedin';
import * as logger from './logger';
import { activePlatformsFromSettings } from './platform-settings';
import * as threads from './threads';
import * as x from './x';
import { buildDailyInventoryPlan, type DailyInventoryQueueRow } from './daily-inventory-planner';
Expand Down Expand Up @@ -1514,12 +1515,7 @@ async function loadTenantContext(userId: string): Promise<TenantContext> {
}))[0];

const credentials = decryptTenantCredentials(credentialRow);
const activePlatforms: PlatformKey[] = [];
if (settings.threads_enabled ?? true) activePlatforms.push('threads');
if (settings.instagram_enabled ?? true) activePlatforms.push('instagram');
if (settings.linkedin_enabled ?? true) activePlatforms.push('linkedin');
if (settings.x_enabled ?? false) activePlatforms.push('x');
if (settings.facebook_enabled ?? false) activePlatforms.push('facebook');
const activePlatforms = activePlatformsFromSettings(settings);

return {
userId,
Expand Down
58 changes: 58 additions & 0 deletions test/tenant-platform-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';

import { activePlatformsFromSettings } from '../src/platform-settings';

assert.deepEqual(
activePlatformsFromSettings({}),
[],
'missing platform settings must fail closed'
);

assert.deepEqual(
activePlatformsFromSettings({
threads_enabled: null,
instagram_enabled: null,
linkedin_enabled: null,
x_enabled: null,
facebook_enabled: null,
}),
[],
'null platform settings must fail closed'
);

assert.deepEqual(
activePlatformsFromSettings({
threads_enabled: false,
instagram_enabled: false,
linkedin_enabled: false,
x_enabled: false,
facebook_enabled: false,
}),
[],
'false platform settings must remain disabled'
);

assert.deepEqual(
activePlatformsFromSettings({
threads_enabled: true,
instagram_enabled: false,
linkedin_enabled: null,
x_enabled: true,
}),
['threads', 'x'],
'only explicit true settings must become active'
);

assert.deepEqual(
activePlatformsFromSettings({
threads_enabled: true,
instagram_enabled: true,
linkedin_enabled: true,
x_enabled: true,
facebook_enabled: true,
}),
['threads', 'instagram', 'linkedin', 'x', 'facebook'],
'explicit opt-in must preserve canonical platform order'
);

console.log('tenant platform policy tests passed');
Loading