Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
155 commits
Select commit Hold shift + click to select a range
c9f6e8e
Add the passkey-assurance dimension — a passkey is not a passkey
claude Jul 28, 2026
b987203
Fix two Codex findings on the passkey dimension; decline the third wi…
claude Jul 28, 2026
bf11356
Fix Codex round 2: bind the verdict to its subject, reject mixed iden…
claude Jul 28, 2026
6c9a76d
Fix Codex round 3: the aggregate must prove its credential set is whole
claude Jul 28, 2026
101b6a8
passkey-assurance: close three grant paths a review found
claude Jul 28, 2026
e91f795
SELF_REVIEW: name the blind spot the two guards do not cover
claude Jul 28, 2026
7985a04
mutation guard: close the last QUEUED gap, classify four survivors by…
claude Jul 28, 2026
51484ce
chore: supersede the pre-merge lane history
claude Jul 28, 2026
da58e59
truthfulness: fix a false safety guarantee, a wrong pre-push gate, an…
claude Jul 29, 2026
d5c347c
chore: supersede the pre-merge lane history
claude Jul 29, 2026
bc49f8b
docs: correct eleven stale figures, each re-derived rather than trusted
claude Jul 29, 2026
5cba9a4
chore: supersede the pre-merge lane history
claude Jul 29, 2026
295823c
mac lane: run the full suite on macOS CI, and close the hole that wou…
claude Jul 29, 2026
36cd242
product profile: stop publishing owner bearers, and gate the two unau…
claude Jul 29, 2026
a918d14
chore: supersede the pre-merge lane history
claude Jul 29, 2026
1d59720
figure guard: close two exemption holes that let a wrong number pass
claude Jul 29, 2026
52658fa
chore: supersede the pre-merge lane history
claude Jul 29, 2026
5386af3
tenant isolation: make the scoping a gate instead of two spot-checks
claude Jul 29, 2026
9423cc7
chore: supersede the pre-merge lane history
claude Jul 29, 2026
d4390c6
fix(ci): rename the isolation proof so it stops tripping the unsafe-p…
claude Jul 29, 2026
653a0a7
mac lane: provide the Redis the suite needs, and record what the firs…
claude Jul 29, 2026
6eba8ab
chore: supersede the pre-merge lane history
claude Jul 29, 2026
c8eb7bf
Workflows: make both lanes explain their own failures
claude Jul 29, 2026
4448b77
chore: reconcile lane history with the squash-merged default
claude Jul 29, 2026
41d33a9
Promote Tier: handle the failure that was actually happening (403, no…
claude Jul 29, 2026
7c9d8c0
Mac lane: record the run that verified the Redis fix (88 passed, 0 fa…
claude Jul 29, 2026
669fb0a
Tier state: measure the promotion instead of inferring it, and stop s…
claude Jul 29, 2026
f1859bd
Graph posture: a grant needs every input confirmed, not five bad valu…
claude Jul 29, 2026
4a858ca
chore: supersede the lane history squash-merged as #148
claude Jul 29, 2026
0385584
Apple schema: enforce the cross-pin invariant the code only stated
claude Jul 29, 2026
ec526a3
uem: bring MDM/UEM under connector discipline, and delete the actuators
claude Jul 29, 2026
436c131
uem: bring MDM/UEM under connector discipline, and delete the actuators
claude Jul 29, 2026
2e80582
chore: rebase the lane onto the #149 squash so this PR shows only its…
claude Jul 29, 2026
1a9359e
Connector discipline: make it a gate, so the next uem/ fails the build
claude Jul 29, 2026
2d8b949
chore: supersede the lane history squash-merged as #150
claude Jul 29, 2026
5e42f4b
nac: remove the ISE/ClearPass quarantine actuators, and close the las…
claude Jul 29, 2026
5034dc7
chore: supersede the lane history squash-merged as #151
claude Jul 29, 2026
0d31445
uem: read supervision through ownership, so BYOD stops failing forever
claude Jul 29, 2026
dc55f67
entitlement-binding: grade whether a grant is REVIEWABLE, not just co…
claude Jul 30, 2026
5aadb01
provisioning: enforce step order, because the numbering on the diagra…
claude Jul 30, 2026
470774a
Fix nine defects an adversarial review reproduced in this PR's own fo…
claude Jul 30, 2026
54f6fc4
docs: register the four new proofs so CI enforces their counts instea…
claude Jul 30, 2026
c4c65ef
sync: republish the live-sync manifest after registering four proofs
claude Jul 30, 2026
39bd9c1
network-nac: actually evaluate the segment, instead of naming a verdi…
claude Jul 30, 2026
2ff8616
response-accountability: catch the watermelon — closed, but not actua…
claude Jul 30, 2026
1f33311
deviceResolver: enforce the read-only boundary where adapters actuall…
claude Jul 30, 2026
6004ef3
integrations: key the connector config stores per tenant
claude Jul 30, 2026
54e2800
nac: cover the fabrication fix, and make the echo unrepresentable
claude Jul 30, 2026
66bfdcb
phase-gate: a disclaimer is not a claim
claude Jul 30, 2026
989732e
response-accountability: a watermelon inside the watermelon detector
claude Jul 30, 2026
bdaf42c
response-accountability: "within target" when no target exists
claude Jul 30, 2026
2bd8644
response-accountability: resolution timing (SLA, time-to-restore, bac…
claude Jul 30, 2026
b3a1e15
ci: derive the CI/preflight proof list instead of trusting it
claude Jul 30, 2026
bc1c02f
docs: position against the operational data platform / ontology category
claude Jul 30, 2026
e35c3f2
syslog: stop reporting 'sent' for events that were never transmitted
claude Jul 31, 2026
70b12e9
response-accountability: a user's confirmation is not a signal re-check
claude Jul 31, 2026
ef37909
policy-binding: a correct binding to a policy that does not act
claude Jul 31, 2026
45a0820
sync: republish the live manifest for the policy-binding count
claude Jul 31, 2026
bc35f4c
benchmark-selection: which CIS benchmark graded this device, and from…
claude Jul 31, 2026
e0aaf1d
core: the /v1 arm of benchmark-selection — a wrong test now changes t…
claude Jul 31, 2026
8b9d749
emitters: bring all five outbound families under connector discipline
claude Jul 31, 2026
0036c4f
guard: register the five emitter gates with the mutation guard
claude Jul 31, 2026
3c37d64
mac: one-command full local simulation — proofs, API, MCP, iOS, mimic…
claude Jul 31, 2026
f8759f9
intake: ledger row 10 — M365 support-tier poster, covered, nothing built
claude Jul 31, 2026
92e49e2
benchmark-selection: the recency axis — a confirmed selection must no…
claude Jul 31, 2026
e9b1be6
intake: rows 12-14 — ServiceNow taxonomy, Fabric IQ HR ontology, GitH…
claude Jul 31, 2026
113e055
shift-context: the labor plane — right person, wrong time is still th…
claude Jul 31, 2026
d125a0a
Merge origin/SignalGrid_Alpha: reconcile the parallel nac + webhooks …
claude Jul 31, 2026
c2e11ee
incident-playbook: route shift_context to Identity & Access, never th…
claude Jul 31, 2026
f209639
shift-context /v1 arm + the off-the-clock demo scenario
claude Jul 31, 2026
1e4d2b3
intake row 16: the Facility Trust Graph — design accepted, phased bui…
claude Jul 31, 2026
f74bf6c
Facility Trust Graph phase 1: the canonical space model + location ce…
claude Jul 31, 2026
61b88d7
mcp: expose the Facility Trust Graph to the chat — facility_graph + e…
claude Jul 31, 2026
51bf3c7
mcp: keep the chat connection current — self-updating launcher + surf…
claude Jul 31, 2026
968252e
Facility Trust Graph phase 2: doors are portals, and a crossing is ev…
claude Jul 31, 2026
74fc509
Lane coordination: git is the bus, because chat context is not
claude Jul 31, 2026
b44fc35
Merge SignalGrid_Alpha (225688e): shared emit gate + mdm-profile proo…
claude Jul 31, 2026
d673323
docs: emitter-discipline count 42 -> 43 after the composed syslog pin
claude Jul 31, 2026
d02d885
sync: regenerate manifest for the 43-check emitter-discipline count
claude Jul 31, 2026
8a823d2
Facility Trust Graph phase 3: clinical bed context — the assignment i…
claude Jul 31, 2026
9f03c7f
Zone-presence transitions: presence is earned, exit is confirmed, sil…
claude Jul 31, 2026
087235a
docs: file the row-17 research report as its durable source artifact
claude Jul 31, 2026
caccdcd
Facility Trust Graph phase 4: the gateway core — the sensitive join s…
claude Jul 31, 2026
f4f88fe
Setup completion: was the device released before day zero finished? (…
claude Jul 31, 2026
0b6faf4
Merge SignalGrid_Alpha (e1891f7): the live Fleet/Wazuh lanes and the …
claude Jul 31, 2026
17e5c6d
bootstrap-credential: a temporary pass reaches enrollment only — the …
claude Jul 31, 2026
a96d14d
app-update: the stability axis — a crashing host app is not a healthy…
claude Jul 31, 2026
63ac9f9
Merge remote-tracking branch 'origin/SignalGrid_Alpha' into claude/si…
claude Jul 31, 2026
f400c79
Wire proof:absent-collection into CI — the drift the gate was built t…
claude Jul 31, 2026
5db0b69
caep-events: the session-signal emitter — the sixth outbound family (…
claude Jul 31, 2026
a3cfc84
Intake row 20: "MCP in One Picture" poster — COVERED, built nothing
claude Jul 31, 2026
b5e7104
Merge remote-tracking branch 'origin/SignalGrid_Alpha' into claude/si…
claude Jul 31, 2026
cb71555
read-error-swallowing guard: reconcile exemptions with this branch's …
claude Jul 31, 2026
1b963eb
pacs-access: the credential-technology (mixed-estate) axis — intake r…
claude Jul 31, 2026
32193a6
Intake row 22: HID hybrid-access blog + drivers/SDK links — COVERED, …
claude Jul 31, 2026
78d3e77
challenge-capability: the answerable step-up — the 42nd family (intak…
claude Aug 1, 2026
ee61d7c
challenge-capability: kill the four connector-shell survivors (38/38,…
claude Aug 1, 2026
8a74d29
sso-session: shared-account attribution axis — intake row 24
claude Aug 1, 2026
d5abb9e
ledger: de-comma the row-24 enumeration figure — the figure guard's s…
claude Aug 1, 2026
d2483a6
sse-egress: the mandated edge path — the 43rd family (intake row 25)
claude Aug 1, 2026
d2e6298
sse-egress: kill the two connector-shell survivors (32/32, sweep clean)
claude Aug 1, 2026
e0da22b
pacs-access: recency + controller-health axes, and the owner's PACS API
claude Aug 1, 2026
ed01c8f
inspiration: file the owner's Endpoint Management API catalog (row 27…
claude Aug 1, 2026
4a1eb7a
access-governance: the lifecycle (J and M) axis — intake row 27, part 2
claude Aug 1, 2026
a04752c
Intake row 28: the Technology Ecosystem Master Catalog — filed as the
claude Aug 1, 2026
39ef626
docs: intake ledger row 29 — firewall-troubleshooting poster (COVERED)
claude Aug 1, 2026
1af9683
docs: note the re-supplied master catalog as a verified duplicate of …
claude Aug 1, 2026
b094a61
docs: intake rows 30-31 PENDING (DNS comparison poster; ITSM platform…
claude Aug 1, 2026
0fb8a1d
docs: close intake rows 30-31 (DNS poster; ITSM article) — both COVER…
claude Aug 1, 2026
d31c4c6
docs: intake row 32 PENDING (Azure Private Networking Notes carousel)
claude Aug 1, 2026
ced7c5f
docs: intake row 32 — record the carousel's second batch (pages 6-15)
claude Aug 1, 2026
4df3f12
docs: close row 32 (Azure networking, zero gaps) + file row-33 mobile…
claude Aug 1, 2026
a6caa2f
docs: close intake row 33 — mobile catalog bundle FILED + QUEUED + CO…
claude Aug 1, 2026
2bc4cc9
docs: intake row 34 — adopt the IT operating-stack layer map (owner-d…
claude Aug 1, 2026
b89085b
docs: intake ledger row 35 — DevOps Roadmap poster (COVERED / OUT OF …
claude Aug 1, 2026
0058cb5
docs: intake row 36 PENDING + file the ControlUp DEX/EUC API catalog
claude Aug 2, 2026
87ccf44
docs: close intake row 36 — ControlUp DEX/EUC catalog FILED + COVERED…
claude Aug 2, 2026
5c382d2
docs: intake ledger row 37 — Hospital IT vs Normal IT poster (COVERED…
claude Aug 2, 2026
1715905
docs: intake ledger rows 38-40 — NHI poster, IT roadmap, CyberSecurit…
claude Aug 2, 2026
79edd4d
docs: intake rows 41-43 PENDING — AI ecosystem, 15 IAM concepts, 8 fr…
claude Aug 2, 2026
1d31eb9
feat: governance-read recency axis on access-governance + close intak…
claude Aug 2, 2026
0fa2f6e
docs: intake row 44 PENDING + file the Asset Management & IT Governan…
claude Aug 2, 2026
d4a0336
docs: close intake row 44 — asset-governance catalog FILED, zero gaps…
claude Aug 2, 2026
a5e8ad2
docs: intake row 45 PENDING — MITRE Defending-OT-with-ATT&CK architec…
claude Aug 2, 2026
569aaa7
docs: intake row 46 — Crucix as Watchtower architecture reference (PO…
claude Aug 2, 2026
09b4344
docs: intake row 45 — compile the OT/ICS/SCADA catalog + refuse the s…
claude Aug 2, 2026
e6aa92d
docs: intake row 47 PENDING + file the Communications Systems API cat…
claude Aug 2, 2026
e3110ab
feat(mcp): fabric_status tool — the local chat can ask what the grid …
claude Aug 2, 2026
484baa8
docs: intake row 48 PENDING — Omnissa/CrowdStrike state-reconciliatio…
claude Aug 2, 2026
8ed4a50
docs: correct the dual-control finding — no live defect on any shippe…
claude Aug 2, 2026
6b8b810
docs: close intake rows 47 and 48; delete the dead 'sent'-shaped noti…
claude Aug 2, 2026
e566eb4
Add package-reachability ratchet: a library nobody ships is a library…
claude Aug 2, 2026
9d90731
reachability: add --why, which answers the question the ceiling cannot
claude Aug 2, 2026
2589039
Build the change-window dimension: the approval is a claim about a sp…
claude Aug 2, 2026
89229b4
incident-playbook: route change_window, caught by its own derived enu…
claude Aug 2, 2026
b1272a0
docs: the required-checks list was the defect it looked like a contro…
claude Aug 2, 2026
7755819
Row 48 second pass: six more elements, zero gaps, and two corrections…
claude Aug 2, 2026
1e59b9b
Commit A of row 27: one digest body, and the legacy digest pinned bef…
claude Aug 2, 2026
a602ec2
Commit B of row 27: a provenance stamp that cannot be written by hand
claude Aug 2, 2026
41e72ab
Regenerate the normalization-version artifacts the wiring invalidated
claude Aug 2, 2026
c361eb4
Row 27 contract sync: the stamp reaches the wire, the console and the…
claude Aug 2, 2026
076d663
The MCP server manufactured two affirmatives its caller never made
claude Aug 2, 2026
db06a7c
Only ENOENT is genesis: close the version-reset hole in the generator
claude Aug 2, 2026
bbb44a1
Merge SignalGrid_Alpha: both lanes wrote an MCP proof; keep both
claude Aug 2, 2026
ce3e04f
The MCP tools advertised additionalProperties:false and enforced none…
claude Aug 2, 2026
f3fe8e3
Room-entry release inputs: prove the fail-closed default, and say wha…
claude Aug 2, 2026
b9d69f9
A typo'd `enable: false` silently re-enabled the connector
claude Aug 3, 2026
f957807
Intake row 49: the agent poster names where SignalGrid actually sits
claude Aug 3, 2026
35acd9b
Intake row 50: Workspace ONE ACCESS 26.07 — wrong product to worry about
claude Aug 3, 2026
41026df
Offline-first intake (row 51): which decision wins across a partition
claude Aug 3, 2026
38bacb4
Figure guard: a table row that names its own proof is its own scope
claude Aug 3, 2026
d311984
Webhooks: mark the trap instead of pre-fixing an unreachable one
claude Aug 3, 2026
17b1c6b
/v1 arm for decision reconciliation: POST /v1/decisions/reconcile
claude Aug 3, 2026
c2b91b4
reconcile: pin that the route sits below the auth guard
claude Aug 3, 2026
8b6d9f8
Operator console: show which decision wins after a partition
claude Aug 3, 2026
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
75 changes: 75 additions & 0 deletions .github/workflows/review-hub-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ jobs:
run: pnpm run proof:macos-posture
- name: "Proof: uem (read-only MDM/UEM, gated, no actuators)"
run: pnpm run proof:uem
- name: "Proof: entitlement-binding (grant reviewability)"
run: pnpm run proof:entitlement-binding
- name: "Proof: response-accountability (watermelon detection)"
run: pnpm run proof:response-accountability
- name: "Proof: device-resolver (read-only injection boundary)"
run: pnpm run proof:device-resolver
- name: "Proof: config-scope (per-tenant connector config keys)"
run: pnpm run proof:config-scope
- name: "Proof: unsafe-claim (negation-aware claim scan)"
run: pnpm run proof:unsafe-claim
# Both of these ran in preflight but in NO workflow — proven only on a developer
# machine while every status page said "CI runs the full gate suite".
# `check-ci-preflight-sync.mjs` now derives this list rather than trusting it.
- name: "Proof: dual-control (two-person integrity)"
run: pnpm run proof:dual-control
- name: "Proof: session-store (in-memory lifecycle)"
run: pnpm run proof:session-store
- name: "Proof: provisioning-order (zero-touch step order)"
run: pnpm run proof:provisioning-order
- name: "Proof: nac (read-only endpoint identity, gated, no actuators)"
run: pnpm run proof:nac

- name: macOS posture ↔ apple/device-management schema alignment proof
run: pnpm run proof:macos-apple-schema
Expand Down Expand Up @@ -173,9 +194,43 @@ jobs:
run: pnpm run proof:platform-sso
- name: Proof — passkey-assurance (credential worth, not just "passkey")
run: pnpm run proof:passkey-assurance
- name: Proof — change-window (approved WHEN, by WHOM, on a current record)
run: pnpm run proof:change-window

# MCP answer discipline — the SECOND of two MCP proofs, and deliberately so.
# `proof:mcp-server` (further down, from the Mac lane) asks whether the published
# plugin path boots and serves the tools the manifest declares. This asks whether
# what it serves is EARNED: every optional input is a claim, and omitting one must
# not be answered as an affirmative. Different questions, both load-bearing.
- name: Proof — mcp-answer-discipline (silence is not an affirmative)
run: pnpm run proof:mcp-answer-discipline

# Policy binding — membership IS the policy: unbound, too-wide (fail-open),
# too-narrow, or mixed-membership group assignment.
- name: Proof — emitter-discipline (five outbound families gated, fixture never claims delivery)
run: pnpm run proof:emitter-discipline
- name: Proof — emit-gate (one shared tier gate for every in-adapter emitter route)
run: pnpm run proof:emit-gate
- name: Proof — absent-collection (nothing observed is not nothing wrong)
run: pnpm run proof:absent-collection
- name: Proof — caep-events (unsigned session signals, sixth emitter family)
run: pnpm run proof:caep-events
- name: Proof — mdm-profile (the shipped profiles say what the product claims)
run: pnpm run proof:mdm-profile
- name: Proof — benchmark-selection (which CIS benchmark graded this device, and from what content)
run: pnpm run proof:benchmark-selection
- name: Proof — shift-context (right time and site for this worker to be operating)
run: pnpm run proof:shift-context
- name: Proof — bootstrap-credential (a temporary pass reaches enrollment only)
run: pnpm run proof:bootstrap-credential
- name: Proof — challenge-capability (a step_up must be answerable, never a deny in disguise)
run: pnpm run proof:challenge-capability
- name: Proof — sse-egress (a mandated edge the traffic is not traversing is never protected)
run: pnpm run proof:sse-egress
- name: Proof — webhooks (outbound delivery gated; a withheld delivery says so)
run: pnpm run proof:webhooks
- name: Proof — facility-trust-graph (canonical space model + location certainty)
run: pnpm run proof:facility-trust-graph
- name: Proof — policy-binding (group-assignment correctness)
run: pnpm run proof:policy-binding

Expand Down Expand Up @@ -258,6 +313,18 @@ jobs:
# All seven are pure Node and need no external service.
- name: "Doc orphans (a new doc must be reachable from an index)"
run: node scripts/check-doc-orphans.mjs
# The same shape one level down: doc-orphans asks whether a reader can reach a
# document, this asks whether a shipped artifact can reach a library. Both catch
# the failure mode that never announces itself — everything green, nothing
# arrives. Added after a design pass found `lib/dual-control` had zero shipped
# consumers AFTER the work to wire it had already been scoped.
- name: "Package reachability (a library nobody ships is a library nobody runs)"
run: node scripts/check-package-reachability.mjs
# The provenance stamp is GENERATED, so the gate is "regenerate and compare
# content" — deliberately not `git diff`, which is blind to untracked files and
# would have passed on the very commit that introduced the artifacts.
- name: "Core normalization-version (provenance stamp tracks the covered core source)"
run: node scripts/generate-core-normalization-version.mjs --check
- name: "Proof — dual control"
run: pnpm run proof:dual-control
- name: "Pagination-truncation guard (a capped read must not look complete)"
Expand All @@ -283,6 +350,8 @@ jobs:
# hardware can refresh that, so staleness never blocks a commit.
- name: Live-sync manifest drift (Mac MCP + iOS instructions current)
run: node scripts/check-live-sync.mjs
- name: MCP surface drift gate (server = docs = ready message = manifest)
run: node scripts/check-mcp-surface.mjs

- name: Docs↔proof figure guard
run: node scripts/check-proof-figures.mjs
Expand Down Expand Up @@ -410,6 +479,9 @@ jobs:
- name: Edge-sync (config-down integrity) proof
run: pnpm run proof:edge-sync

- name: Decision-continuity (which decision wins across a partition) proof
run: pnpm run proof:decision-continuity

- name: Telemetry-up (decision plane → control plane) proof
run: pnpm run proof:telemetry-up

Expand All @@ -434,6 +506,9 @@ jobs:
# Required docs exist + affirmative-unsafe-claim scan. Shared with
# `pnpm run preflight` via scripts/docs-sanity.mjs so the check is the same
# locally and in CI.
- name: CI↔preflight drift (every proof runs in both places)
run: node scripts/check-ci-preflight-sync.mjs

- name: Docs sanity (required docs + unsafe-claim scan)
run: node scripts/docs-sanity.mjs

Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ proofs miss:
— badge, location/zone, injected signals, screen-capture, seeded control-plane
refs, etc. Pass via `xcrun simctl launch booted com.enterprise.shell -Flag ...`.

## Multiple Claude lanes

Parallel Claude sessions work this repo (cloud + Mac). Before touching a
shared surface (discipline gate, mutation guard, sync manifest, proof
registration, connector families the other lane's commits name), read
`docs/LANE_COORDINATION.md` and follow its protocol — the nac/webhooks
eight-file collision is why it exists.

## Ask before

Destructive git (force-push, history rewrite, branch deletion), anything that
Expand Down
15 changes: 11 additions & 4 deletions artifacts/api-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,17 @@ app.get(["/", "/console"], (_req, res) => {
res.type("html").send(CONSOLE_HTML);
});

// Prometheus scrape endpoint (operational metrics). Unauthenticated and outside
// the /v1 contract surface, per Prometheus convention; contains only aggregate
// counters/latencies, never request payloads.
app.get("/metrics", (_req, res) => {
// Prometheus scrape endpoint (operational metrics). Global AGGREGATE only —
// counters/latencies with no tenant label and no request payloads, so the
// endpoint can never become a cross-tenant side channel. Open by default per
// Prometheus convention; setting METRICS_TOKEN requires scrapers to present it
// as a bearer, without breaking deployments that never set it.
app.get("/metrics", (req, res) => {
const required = process.env.METRICS_TOKEN?.trim();
if (required && req.headers.authorization !== `Bearer ${required}`) {
res.status(401).type("text/plain").send("metrics: bearer token required");
return;
}
res.type("text/plain; version=0.0.4").send(renderMetrics(Date.now()));
});

Expand Down
115 changes: 114 additions & 1 deletion artifacts/api-server/src/routes/v1.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Router, type IRouter, type Request, type Response, type NextFunction } from "express";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { CoreError, verifySnapshot, type EvaluateRequest } from "@workspace/signalgrid-core";
import {
CoreError,
reconcileDecisions,
verifySnapshot,
type EvaluateRequest,
type ReconcilableDecision,
type StandingBound,
} from "@workspace/signalgrid-core";
import { getDecisionStore, getSessionStore, type Session } from "@workspace/persistence";
import { listAppIntegrations, findAppIntegration, planAppSession } from "@workspace/app-workflows";
import { webauthn, webauthnStore } from "@workspace/webauthn";
Expand Down Expand Up @@ -125,6 +132,32 @@ router.get("/v1/decisions/:id/evidence", async (req: Request, res: Response, nex
}
});

/**
* Reconcile decisions that were made on both sides of a network partition.
*
* A frontline device that keeps working offline keeps DECIDING offline, so on
* reconnect two answers exist for one subject and action. `reconcileDecisions`
* (`lib/signalgrid-core/src/continuity.ts`) says which one stands; this is its wire arm.
*
* THE ROUTE STORES NOTHING AND READS NOTHING. Every record is caller-supplied and the
* reduction is pure, so there is no decision id to mint, no evidence snapshot, and
* nothing to persist. That is deliberate: the reconciler answers a question about
* records the caller already holds, and minting a new decision here would create a
* record with no evidence behind it.
*
* WHAT THIS PARSER DELIBERATELY DOES NOT DO: fill anything in. `evaluatedOffline` and
* `policyKnownSuperseded` are passed through exactly as sent, absent included, so the
* library's refusal is what the caller meets. A `?? false` here would be the MCP
* adapter's defect at a different layer — an omitted field buying the record the right
* to relax — and it would be invisible from the wire, because a defaulted request and
* an honest one produce the same 200.
*/
router.post("/v1/decisions/reconcile", (req: Request, res: Response) => {
const { records, standingBound } = parseReconcile(req.body);
const result = reconcileDecisions(records, standingBound ? { standingBound } : {});
res.json(envelope(req, { reconciliation: result }));
});

// ── Sessions: durable start / refresh / end lifecycle ────────────────────────
// A session is gated by a real decision at start, then kept alive by refreshes
// until it ends or its TTL lapses. Sessions persist in-memory by default and to
Expand Down Expand Up @@ -688,6 +721,86 @@ function parseEvaluate(body: unknown): EvaluateRequest {
return { identityRef, deviceRef, workflowKey, requestContext };
}

/**
* How many decision records one reconcile call may carry.
*
* The reduction computes a Pareto frontier, which is O(n²) in the record count, so an
* unbounded array is a cost the caller controls. The bound REFUSES rather than
* truncates, and that is the load-bearing half: truncating would silently drop records
* from the set, and dropping a record can only ever remove a restriction — the same
* asymmetry that makes an expired local decision get RAISED to a floor instead of
* dropped. A partial answer here would be indistinguishable from a complete one.
*
* Sized for what the surface actually is: the decisions held for ONE subject and action
* across a partition, which is a handful in practice. A caller that genuinely has more
* has a different problem than reconciliation.
*/
const MAX_RECONCILE_RECORDS = 64;

/**
* Parse a reconcile request WITHOUT completing it.
*
* Shape and type are checked here so a malformed body is a clean 400 instead of a
* library exception; SEMANTICS are left entirely to `reconcileDecisions`, which already
* refuses an unstated `evaluatedOffline`, a non-integer `policyVersion`, a negative
* elapsed, a duplicate id carrying two different answers, and an empty set. Re-checking
* those here would create a second place for the rules to live and a second place for
* them to drift.
*/
function parseReconcile(body: unknown): {
records: ReconcilableDecision[];
standingBound?: StandingBound;
} {
if (!body || typeof body !== "object") {
throw new CoreError("validation", "Request body must be a JSON object.", 400);
}
const record = body as Record<string, unknown>;
const raw = record["records"];
if (!Array.isArray(raw)) {
throw new CoreError("validation", "records must be an array of decision records.", 400);
}
if (raw.length > MAX_RECONCILE_RECORDS) {
throw new CoreError(
"validation",
`records may carry at most ${MAX_RECONCILE_RECORDS} decisions; ${raw.length} were sent. ` +
"The request is refused rather than truncated — a dropped record can only remove a restriction.",
400,
);
}
for (const entry of raw) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new CoreError("validation", "Each record must be a JSON object.", 400);
}
const provenance = (entry as Record<string, unknown>)["provenance"];
if (!provenance || typeof provenance !== "object" || Array.isArray(provenance)) {
throw new CoreError("validation", "Each record must carry a provenance object.", 400);
}
}
const records = raw as ReconcilableDecision[];

const boundRaw = record["standingBound"];
if (boundRaw === undefined) return { records };
if (!boundRaw || typeof boundRaw !== "object" || Array.isArray(boundRaw)) {
throw new CoreError("validation", "standingBound must be a JSON object when present.", 400);
}
const bound = boundRaw as Record<string, unknown>;
const elapsed = bound["elapsedSecondsById"];
if (elapsed !== undefined && (!elapsed || typeof elapsed !== "object" || Array.isArray(elapsed))) {
throw new CoreError("validation", "standingBound.elapsedSecondsById must be an object.", 400);
}
// A missing `elapsedSecondsById` becomes an EMPTY map rather than an absent bound —
// so every offline record reads as age-unstated and expires. Treating it as "no bound
// posed" would let a caller pose a bound and then escape it by omitting the ages,
// which is the shape this whole surface exists to refuse.
return {
records,
standingBound: {
...(bound as unknown as StandingBound),
elapsedSecondsById: (elapsed ?? {}) as Record<string, number>,
},
};
}

const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
// Linear, length-bounded key pattern (no nested quantifiers → no ReDoS).
const CONTEXT_KEY = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
Expand Down
Loading