Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
25 changes: 21 additions & 4 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Root `ARCHITECTURE.md` / `DESIGN.md` are compatibility redirects only. Not live
| **R1** | Cluster Index: 2×2 meta-tiles, click → Roost (≤12) | R0 | MEDIUM | shipped | FINAL_DESIGN §6 |
| **R2** | Node chrome: executing / racing / rolled-back / denied / attested | R1 | MEDIUM | shipped | BRAIN `provider_06` slice |
| **R3** | Rack: pin 2–4 Roosts | R1 | MEDIUM | this PR | FINAL_DESIGN §6 |
| **S1** | Consume SPX402 Grade + confidence on Index / OG (read-only) | `reference/spx402` | MEDIUM | NEXT | BRAIN Grade strip |
| **S2** | Emit `OC_*` evidence into SPX402 (`task_executor` decoder) | S1, SPX402 upstream | HIGH | — | `reference/spx402/FLOK_EXTENSION.md` |
| **S1** | Consume SPX402 Grade + confidence on Index / OG (read-only) | `reference/spx402` | MEDIUM | shipped | BRAIN Grade strip |
| **S2** | Emit `OC_*` evidence into SPX402 (`task_executor` decoder) | S1, SPX402 upstream | HIGH | NEXT | `reference/spx402/FLOK_EXTENSION.md` |
| **T1** | Night Tape + Spotlight (no Sky) | ~50 live Floks | HIGH | gated | FINAL_DESIGN §7 |
| **E1** | Outcome Contract object + public header | S2 live (or Hall shows SPX404) | HIGH | gated | BRAIN `buyer_04`, `buyer_10` |
| **E2** | AEON escrow as middleman | E1 | CRITICAL | gated | BRAIN `provider_10` |
Expand Down Expand Up @@ -84,6 +84,22 @@ OC_OPENED → OC_AWARDED → OC_FULFILLED | OC_FAILED | OC_SLASHED

Do not reuse `TASK_COMPLETED`. Hall stays closed while `decoderLive === false`.

**S2 Gate 1 — Flok-owned evidence boundary is done when:**

- [x] 1. The exact `OC_*` taxonomy and lifecycle are runtime-validated.
- [x] 2. Flok canonicalizes evidence and computes `sha256:…` server-side.
- [x] 3. Event identity is deterministic and retries classify as duplicates.
- [x] 4. Lifecycle state is read under a database row lock, never supplied by a caller.
- [x] 5. Evidence append and outbox enqueue commit in one transaction.
- [x] 6. The subject is resolved server-side from the shared `handle/cluster-slug` map.
- [x] 7. Decoder probing and emission fail closed with no ingestion egress.

**Still upstream-blocked:** SPX402 has no authenticated `OC_*` ingestion
contract, canonical `flok.oc-evidence.v1` → `spx.evidence.v1` mapping, or live
`task_executor` decoder. Until all three ship, outbox rows remain pending,
`decoderLive` remains false, and Hire Hall remains closed. Upstream scoring must
use an upstream-observed timestamp rather than trusting Flok's `occurred_at`.

---

## BRAIN (Contracts / Roost / Capsules)
Expand Down Expand Up @@ -157,5 +173,6 @@ Do not wait on Cluster/Hall.
- [x] R1 Cluster Index + Roost
- [x] R2 Node chrome (executing / racing / rolled-back / denied / attested)
- [x] R3 Rack
- [ ] S1 Grade or SPX404 on the Index tile
- [ ] Hall still closed until S2
- [x] S1 Grade or SPX404 on the Index tile
- [ ] S2 `OC_*` evidence (`task_executor` decoder) — in progress
- [ ] Hall still closed until S2 is live (`decoderLive === true`)
58 changes: 58 additions & 0 deletions migrations/0006_oc_evidence.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- S2 Gate 1: durable Outcome Contract lifecycle and transactional delivery outbox.
-- Hall remains closed; pending rows have no sender until the upstream decoder is live.

create table if not exists oc_lifecycle (
contract_id text primary key,
latest_event_id text,
cluster_id text,
cluster_slug text,
subject text,
current_type text check (
current_type is null or current_type in (
'OC_OPENED', 'OC_AWARDED', 'OC_FULFILLED', 'OC_FAILED', 'OC_SLASHED'
)
),
current_occurred_at timestamptz,
updated_at timestamptz not null default now()
);

create table if not exists oc_evidence_events (
event_id text primary key,
idempotency_key text not null,
contract_id text not null,
cluster_id text not null,
cluster_slug text not null,
subject text not null,
type text not null check (
type in ('OC_OPENED', 'OC_AWARDED', 'OC_FULFILLED', 'OC_FAILED', 'OC_SLASHED')
),
occurred_at timestamptz not null,
evidence_hash text not null,
capsule_id text,
payload jsonb not null,
created_at timestamptz not null default now(),
unique (contract_id, type, idempotency_key)
);

alter table oc_lifecycle
drop constraint if exists oc_lifecycle_latest_event_id_fkey;
alter table oc_lifecycle
add constraint oc_lifecycle_latest_event_id_fkey
foreign key (latest_event_id) references oc_evidence_events (event_id);

create index if not exists oc_evidence_events_contract_id_idx
on oc_evidence_events (contract_id, occurred_at);

create table if not exists oc_evidence_outbox (
event_id text primary key references oc_evidence_events (event_id) on delete cascade,
payload jsonb not null,
status text not null default 'pending' check (status in ('pending', 'sent')),
attempts integer not null default 0 check (attempts >= 0),
available_at timestamptz not null default now(),
created_at timestamptz not null default now(),
sent_at timestamptz
);

create index if not exists oc_evidence_outbox_pending_idx
on oc_evidence_outbox (available_at, created_at)
where status = 'pending';
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build:dev": "vite build --mode development",
"preview": "vite preview --host 0.0.0.0 --port 8080",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test 'scripts/**/*.test.mjs' 'src/**/*.test.ts'",
"test": "node scripts/run-tests.mjs",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
Expand Down
27 changes: 27 additions & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { spawnSync } from "node:child_process";

async function collectTests(directory) {
const tests = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
tests.push(...(await collectTests(path)));
} else if (entry.name.endsWith(".test.mjs") || entry.name.endsWith(".test.ts")) {
tests.push(path);
}
}
return tests;
}

const testFiles = (await Promise.all(["scripts", "src"].map(collectTests))).flat().sort();
if (testFiles.length === 0) {
throw new Error("No test files found");
}

const result = spawnSync(process.execPath, ["--experimental-strip-types", "--test", ...testFiles], {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
75 changes: 66 additions & 9 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
export interface Sql {
<T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
query<T = Record<string, unknown>>(text: string, params?: unknown[]): Promise<T[]>;
transaction<T>(callback: (sql: Sql) => Promise<T>): Promise<T>;
}

/**
Expand Down Expand Up @@ -58,9 +59,10 @@
const identity = (v: string) => v;

type Run = <T>(text: string, params: unknown[]) => Promise<T[]>;
type Transact = <T>(callback: (sql: Sql) => Promise<T>) => Promise<T>;

/** Wrap a query runner in the tagged-template + `.query()` `Sql` surface. */
function toSql(run: Run): Sql {
function toSql(run: Run, transact: Transact): Sql {
const sql = (async <T = Record<string, unknown>>(
strings: TemplateStringsArray,
...values: unknown[]
Expand All @@ -72,6 +74,7 @@
}) as unknown as Sql;
sql.query = <T = Record<string, unknown>>(text: string, params: unknown[] = []) =>
run<T>(text, params);
sql.transaction = transact;
return sql;
}

Expand All @@ -84,9 +87,34 @@
types.setTypeParser(OID_DATE, identity);
types.setTypeParser(OID_INTERVAL, identity);
const pool = new Pool({ connectionString: databaseUrl });
return toSql(async <T>(text: string, params: unknown[]) => {
const run: Run = async <T>(text: string, params: unknown[]) => {
const res = await pool.query(text, params);
return res.rows as T[];
};
return toSql(run, async <T>(callback: (sql: Sql) => Promise<T>) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const txRun: Run = async <R>(text: string, params: unknown[]) => {
const result = await client.query(text, params);
return result.rows as R[];
};
const txSql = toSql(txRun, () => {
throw new Error("nested_transactions_not_supported");
});
const result = await callback(txSql);
await client.query("COMMIT");
return result;
try {
Comment thread
Copilot marked this conversation as resolved.
Resolved
await client.query("ROLLBACK");
} catch {
// Preserve the original error if the connection can no longer roll back.
}
throw error;

Check failure on line 113 in src/lib/db.ts

View workflow job for this annotation

GitHub Actions / Quality

Cannot find name 'error'. Did you mean 'Error'?
Comment thread
Copilot marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} finally {
Comment thread
Adaptive-Liquidity marked this conversation as resolved.
Resolved
client.release();
client.release();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
})().catch((err) => {
globalRef.__pgSqlPromise__ = undefined;
Expand Down Expand Up @@ -119,17 +147,34 @@
});
const pg = await globalRef.__pgliteInstance__;

const loadMigrations = async (): Promise<Record<string, string>> => {
if (typeof import.meta.glob === "function") {
return import.meta.glob("/migrations/*.sql", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
}
const { readdir, readFile } = await import("node:fs/promises");
const directory = new URL("../../migrations/", import.meta.url);
const files = (await readdir(directory)).filter((name) => name.endsWith(".sql")).sort();
return Object.fromEntries(
await Promise.all(
files.map(async (name) => [
`/migrations/${name}`,
await readFile(new URL(name, directory), "utf8"),
]),
),
);
};

// Apply migrations/ (the single schema source) so preview matches production.
// SQL is inlined by the bundler via import.meta.glob (no runtime fs); applied
// files are tracked in _migrations. Runs once per module instance — so an HMR
// reload after adding a migration file applies it live — with passes
// serialized on a global chain so concurrent callers never double-apply.
const migrate = async (): Promise<void> => {
const migrations = import.meta.glob("/migrations/*.sql", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const migrations = await loadMigrations();
const doneRows = await pg.query<{ name: string }>("select name from _migrations");
const done = new Set(doneRows.rows.map((r) => r.name));
for (const [path, text] of Object.entries(migrations).sort(([a], [b]) => a.localeCompare(b))) {
Expand All @@ -149,10 +194,22 @@
globalRef.__pgliteMigrateChain__ = pass;
await pass;

return toSql(async <T>(text: string, params: unknown[]) => {
const run: Run = async <T>(text: string, params: unknown[]) => {
const result = await pg.query<T>(text, params);
return result.rows;
});
};
return toSql(run, <T>(callback: (sql: Sql) => Promise<T>) =>
pg.transaction(async (tx) => {
const txRun: Run = async <R>(text: string, params: unknown[]) => {
const result = await tx.query<R>(text, params);
return result.rows;
};
const txSql = toSql(txRun, () => {
throw new Error("nested_transactions_not_supported");
});
return callback(txSql);
}),
);
}

let sqlPromise: Promise<Sql> | null = null;
Expand Down
25 changes: 25 additions & 0 deletions src/lib/evidence-hash.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function canonicalJsonStringify(value: unknown): string {
return stringify(value);
}

function stringify(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (Array.isArray(value)) return `[${value.map(stringify).join(",")}]`;
if (typeof value === "object") {
const object = value as Record<string, unknown>;
const keys = Object.keys(object)
.filter((key) => object[key] !== undefined)
.sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${stringify(object[key])}`).join(",")}}`;
}
return "null";
}

export async function sha256Hex(input: string | Uint8Array): Promise<string> {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input.slice();
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
10 changes: 8 additions & 2 deletions src/lib/grade.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { aggregateGrades, parseEvidenceGrade, parseSubjectMap, UNGRADED } from "@/lib/grade";
import {
aggregateGrades,
parseEvidenceGrade,
parseSubjectMap,
resolveSubject,
UNGRADED,
} from "@/lib/grade";
import type { GradeSnapshot } from "@/lib/grade";
import type { ClusterCard } from "@/lib/types";

Expand Down Expand Up @@ -56,7 +62,7 @@ export async function getClusterGrades(
const subjects = parseSubjectMap(process.env.FLOK_SPX402_SUBJECTS);
const entries = await Promise.all(
clusters.map(async (cluster): Promise<[string, GradeSnapshot]> => {
const subject = subjects.get(`${handle}/${cluster.slug}`.toLowerCase());
const subject = resolveSubject(subjects, handle, cluster.slug);
if (!baseUrl || !methodologyVersion || !subject) return [cluster.id, UNGRADED];
return [cluster.id, await fetchGrade(baseUrl, subject, methodologyVersion)];
}),
Expand Down
11 changes: 10 additions & 1 deletion src/lib/grade.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { aggregateGrades, parseEvidenceGrade, parseSubjectMap, UNGRADED } from "./grade.ts";
import { resolveSubject } from "./spx-subject.ts";

const SUBJECT = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk";
const METHODOLOGY = "spx-oc-score-v1";
Expand Down Expand Up @@ -91,9 +92,17 @@ test("aggregateGrades uses the lowest available grade and confidence", () => {

test("parseSubjectMap validates subjects and normalizes keys", () => {
const parsed = parseSubjectMap(
JSON.stringify({ " GrowthOps/Outbound ": SUBJECT, "bad/key": "not-a-subject" }),
JSON.stringify({
" GrowthOps/Outbound ": SUBJECT,
"bad/key": "not-a-subject",
"missing-cluster": SUBJECT,
"bad_handle/outbound": SUBJECT,
}),
);
assert.equal(parsed.get("growthops/outbound"), SUBJECT);
assert.equal(resolveSubject(parsed, "GrowthOps", "Outbound"), SUBJECT);
assert.equal(parsed.has("bad/key"), false);
assert.equal(parsed.has("missing-cluster"), false);
assert.equal(parsed.has("bad_handle/outbound"), false);
assert.deepEqual([...parseSubjectMap("{").entries()], []);
});
22 changes: 2 additions & 20 deletions src/lib/grade.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { parseSubjectMap, resolveSubject } from "./spx-subject.ts";

export type Grade =
"SPX AAA" | "SPX AA" | "SPX A" | "SPX BBB" | "SPX BB" | "SPX B" | "SPX D" | "SPX404";

Expand Down Expand Up @@ -104,23 +106,3 @@ export function aggregateGrades(grades: GradeSnapshot[]): GradeSnapshot {
source: "spx402",
};
}

export function parseSubjectMap(raw: string | undefined): Map<string, string> {
if (!raw) return new Map();
try {
const value: unknown = JSON.parse(raw);
if (!isRecord(value)) return new Map();
return new Map(
Object.entries(value)
.filter(
(entry): entry is [string, string] =>
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
/^[1-9A-HJ-NP-Za-km-z]{32,64}$/.test(entry[1]),
)
.map(([key, subject]) => [key.trim().toLowerCase(), subject]),
);
} catch {
return new Map();
}
}
Loading
Loading