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
31 changes: 31 additions & 0 deletions .github/workflows/contract-reconciliation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: contract-reconciliation

# Detection layer for contract drift. Keeps registry/contract-registry.json canonical (fails if
# a schema change didn't regenerate it) and runs the reconciliation tests that catch orphan /
# stale / unregistered contracts — the drift that stopped the canon self-updating.
on:
push:
branches: [main]
paths: ['schemas/**', 'tools/reconcile_contracts.py', 'registry/**', '.github/workflows/contract-reconciliation.yml']
pull_request:
paths: ['schemas/**', 'tools/reconcile_contracts.py', 'registry/**', 'tools/test_reconcile_contracts.py']

permissions:
contents: read

jobs:
reconcile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pytest
- name: Registry is current (a schema change must regenerate it)
run: |
python3 tools/reconcile_contracts.py --emit-registry
git diff --exit-code registry/contract-registry.json \
|| { echo "::error::registry/contract-registry.json is stale — run: python3 tools/reconcile_contracts.py --emit-registry"; exit 1; }
- name: Reconciliation tests (orphan / stale / owned / fail-closed)
run: python3 -m pytest tools/test_reconcile_contracts.py -q
50 changes: 50 additions & 0 deletions .github/workflows/propagate-contracts-on-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: propagate-contracts-on-merge

# Actuation layer — the "merge to main solves its own updates" mechanism that was DESIGNED
# (SourceOSRepoManifest.auditEvents: spec.schema.added) but never actuated. On a merge that
# changes canonical schemas, dispatch a re-sync to every registered consumer so each opens a
# vendored-copy update PR (which the self-heal responder can then land). Closes the propagation
# gap: a contract merged here reaches its consumers without a human chasing each repo.
on:
push:
branches: [main]
paths: ['schemas/**', 'registry/contract-registry.json']

permissions:
contents: read

jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Dispatch a re-sync to every registered consumer
env:
# A token with `repository_dispatch` scope on the consumer repos. Minted in CI; never a PAT.
DISPATCH_TOKEN: ${{ secrets.CONSUMER_DISPATCH_TOKEN }}
run: |
if [ -z "${DISPATCH_TOKEN:-}" ]; then
echo "::warning::CONSUMER_DISPATCH_TOKEN not set — recording the intent, not dispatching."
fi
sha="${GITHUB_SHA}"
python3 - "$sha" <<'PY'
import json, os, subprocess, sys
sha = sys.argv[1]
consumers = json.load(open("registry/consumers.json"))["consumers"]
token = os.environ.get("DISPATCH_TOKEN", "")
for c in consumers:
payload = {"event_type": c["dispatchType"],
"client_payload": {"spec_sha": sha, "schemasDir": c["schemasDir"]}}
print(f"→ {c['repo']}: {c['dispatchType']} @ {sha[:12]}")
if not token:
continue
subprocess.run(
["curl", "-sf", "-X", "POST",
"-H", "Accept: application/vnd.github+json",
"-H", f"Authorization: Bearer {token}",
f"https://api.github.com/repos/{c['repo']}/dispatches",
"-d", json.dumps(payload)],
check=False,
)
print(f"dispatched to {len(consumers)} consumer(s)")
PY
27 changes: 27 additions & 0 deletions CONTRACTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Contract percolation — making sourceos-spec the *actuated* single authority

The estate is designed so every contract has ONE canonical home here, and consumers vendor
copies. The design existed (`SourceOSRepoManifest.ownedSchemas` / `authorityRepos`, and
`auditEvents: [spec.schema.added]`) — but it was **validated, never actuated**. So contracts got
authored elsewhere and never percolated back: `AutonomyAdmissionReceipt` and `QuorumProof` are
live in services yet **absent from this spec**. This directory closes that, two layers per gap.

## The registry (single authority)
`registry/contract-registry.json` is the generated canonical index: every `$id`'d schema this
repo owns, with its sha256. `tools/reconcile_contracts.py --emit-registry` regenerates it; CI
fails if a schema change didn't (the registry can never go stale).

## Gap → two layers

| Gap | Detection layer | Actuation layer |
|---|---|---|
| **1 — no upstream percolation** (consumer→spec) | `reconcile_contracts.py --check-consumer` flags an **ORPHAN**: a schema a consumer vendors that no authority repo owns → it has no canonical home | consumer CI that hits an orphan opens an **upstream PR to this repo** adding the contract (the reverse of the one-way sync) |
| **2 — no propagation on merge** (spec→consumer) | the **STALE** check: a vendored copy whose sha256 ≠ canonical → a propagation was missed | `propagate-contracts-on-merge.yml`: a merge changing `schemas/**` **dispatches a re-sync to every registered consumer** (`registry/consumers.json`), which the self-heal responder lands |
| **3 — no canonical-origin authority** | the **UNREGISTERED** check + `check_duplicate_schema_ids.py`: every live `$id` must resolve to a registry entry owned by an authority repo | `registry/contract-registry.json` + `ownedSchemas`/`authorityRepos` in each manifest make "authored in spec" the single source of truth; CODEOWNERS routes contract changes here |

## Adopting it (consumer side)
Add the repo to `registry/consumers.json`, drop a `.sourceos/manifest.json` declaring
`ownedSchemas` + `authorityRepos`, and run in CI:
`python3 reconcile_contracts.py --check-consumer .sourceos/manifest.json <vendored-schemas-dir>`.
An orphan or stale copy fails the build — the drift that stopped the canon self-updating can no
longer land silently.
17 changes: 17 additions & 0 deletions registry/consumers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"_comment": "Consumers that VENDOR canonical contracts from sourceos-spec. A merge to main that changes schemas/** dispatches a re-sync to each. New consumers add themselves here; the reconciliation gate runs check_consumer against each with its vendored dir.",
"consumers": [
{
"repo": "SocioProphet/socioprophet",
"schemasDir": "socioprophet-web/server/src/contracts/schemas",
"manifest": "socioprophet-web/server/.sourceos/manifest.json",
"dispatchType": "spec-contracts-updated"
},
{
"repo": "SourceOS-Linux/prophet-platform",
"schemasDir": "apps/compute-gateway/src/compute_gateway/schemas",
"manifest": ".sourceos/manifest.json",
"dispatchType": "spec-contracts-updated"
}
]
}
Loading
Loading