diff --git a/.github/workflows/fluid-vendor-sync.yml b/.github/workflows/fluid-vendor-sync.yml new file mode 100644 index 0000000..748d170 --- /dev/null +++ b/.github/workflows/fluid-vendor-sync.yml @@ -0,0 +1,78 @@ +# The vendored FLUID schemas under vendor/fluid/ must match what FLUID publishes. +# +# FLUX does not own the FLUID schema; it references it across the seam. The copies +# in vendor/fluid/ exist so the validator can resolve a referenced contract offline, +# which means they are a cache — and an unguarded cache drifts. +# +# They are in sync today. Nothing enforced that, which is the actual gap: a claim +# about another project's artefact is only true if the CI of the side making the +# claim proves it. That is the rule this repository now applies to itself after +# shipping a false "field-to-field aligned with FLUID" statement through several +# releases (see docs/reconciliation.md). +# +# Comparison is on canonical JSON (sorted keys, no whitespace) so cosmetic +# formatting differences do not fail the build — only a real content divergence does. + +name: fluid-vendor-sync + +on: + push: + branches: [main] + paths: + - "vendor/fluid/**" + - ".github/workflows/fluid-vendor-sync.yml" + pull_request: + paths: + - "vendor/fluid/**" + - ".github/workflows/fluid-vendor-sync.yml" + schedule: + # Weekly: FLUID can publish a new patch without FLUX touching anything, and a + # cache that silently ages is exactly what this guards against. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +env: + FLUID_RAW_BASE: https://raw.githubusercontent.com/open-data-protocol/fluid/main/schema + +jobs: + vendor-sync: + name: vendored FLUID schemas match upstream + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: compare each vendored schema against the published one + run: | + set -euo pipefail + status=0 + for path in vendor/fluid/fluid-schema-*.json; do + name="$(basename "$path")" + if ! curl -fsSL "${FLUID_RAW_BASE}/${name}" -o "/tmp/${name}"; then + echo "::error::${name} is vendored here but not published upstream." + echo "::error::Either it was withdrawn, or this copy was never a real FLUID release." + status=1 + continue + fi + if python3 - "$path" "/tmp/${name}" <<'PY' + import json, sys + canon = lambda p: json.dumps(json.load(open(p)), sort_keys=True, separators=(",", ":")) + sys.exit(0 if canon(sys.argv[1]) == canon(sys.argv[2]) else 1) + PY + then + echo "✅ ${name} matches upstream" + else + echo "::error::${name} has drifted from the published FLUID schema." + echo "::error::Re-vendor it: curl -fsSL ${FLUID_RAW_BASE}/${name} -o ${path}" + echo "::error::then run: python3 scripts/sync-package-data.py" + diff <(python3 -m json.tool --sort-keys "$path") \ + <(python3 -m json.tool --sort-keys "/tmp/${name}") | head -40 || true + status=1 + fi + done + exit $status diff --git a/docs/reconciliation.md b/docs/reconciliation.md index 2102c0b..d07c842 100644 --- a/docs/reconciliation.md +++ b/docs/reconciliation.md @@ -36,17 +36,74 @@ matching FLUID's conventions. ### Governed — delivered in schema, cross-checks in the validator - `agentPolicy` (root-level, all kinds): model allow-list, token budget, - use-case limits — vocabulary aligned with FLUID `exposes[].policy.agentPolicy`. + use-case limits. **Not** field-for-field with FLUID — see "The agentPolicy + divergence" below. - `skills` (root-level, all kinds): the uniform agentic extension point of paper §3.5. Declaring `skills` requires `agentPolicy` (schema-enforced). - `sovereignty` on `World` and `Simulation` (`allowedZones`, `enforcement: block|audit`). -- `ConsentProfile` can no longer be empty, and its vocabulary - (`allowedUseCases`/`deniedUseCases`) matches FLUID's, so seam-crossing policy - comparison is field-to-field. The "contract must be at least as strict as the - gating ConsentProfile" rule is enforced by the reference validator at the - seam: an emitted expose's `policy.agentPolicy` may not allow a use case a - gating profile denies, nor exceed its allow-list (`scripts/validate.py`, - regression-tested). +- `ConsentProfile` can no longer be empty. The "contract must be at least as + strict as the gating ConsentProfile" rule is enforced by the reference + validator at the seam: an emitted expose's `policy.agentPolicy` may not allow + a use case a gating profile denies, nor exceed its allow-list + (`scripts/validate.py`, regression-tested). **That enforcement is real, but + its reach is narrower than this document previously claimed** — see below. + + +## The agentPolicy divergence + +This document previously said FLUX's `agentPolicy` vocabulary was "aligned with +FLUID's" and that "seam-crossing policy comparison is field-to-field". Both +statements were wrong, and the schema carried the same claim in its own +`$defs/agentPolicy` description. They are corrected here rather than quietly +dropped, because a false alignment claim is worse than a documented gap: it +invites implementers to build a comparison that cannot work. + +Measured against `fluid-schema-0.7.5.json`: + +| | FLUX `agentPolicy` | FLUID `exposes[].policy.agentPolicy` | +|---|---|---| +| members | 5 | 13 | +| token budget | `tokenBudget` (integer) | `maxTokensPerRequest` + `maxTokensPerDay` | +| `purposeLimitation` | boolean | string | +| use-case values | free strings (`minLength: 1`) | 12-value enum | +| FLUID-only | — | `deniedModels`, `canReason`, `canStore`, `retentionPolicy`, `auditRequired`, `tags`, `labels` | + +Three members line up by name *and* type: `allowedModels`, `allowedUseCases`, +`deniedUseCases`. + +### What this costs at the seam + +The use-case row is the one that bites. FLUX accepts any non-empty string; +FLUID accepts twelve specific values. So a `ConsentProfile` can deny a use case +that no FLUID contract is able to express, and the "at least as strict" check +then has nothing to bind against on the FLUID side. + +This is not hypothetical. Of the eleven distinct use-case values appearing in +FLUX's own 34 enforcement vectors — `advertising`, `persona_variation`, +`journey_synthesis`, `pricing_optimisation`, `exfiltrate-pii` and the rest — +**none** can be written into a FLUID contract. The seam comparison the previous +wording described has never had a working example in this repository's test +suite. + +What the validator does enforce is still worth having: within FLUX, a +simulation's declared policy is checked against its gating profile, and that is +regression-tested. What it cannot do is carry a FLUX use-case restriction +across the seam into a FLUID contract. + +### Resolving it + +Two options, and this is an open decision, not a plan: + +1. **FLUX rebases onto FLUID's vocabulary** — `$ref` FLUID's published + `agentPolicy` `$id` instead of restating it. Cleanest, and it makes the seam + claim true. It is a breaking change to FLUX (`tokenBudget` splits in two, + `purposeLimitation` changes type, use-case values become constrained) and the + 34 vectors would need rebasing. Cheapest while FLUX has no external + implementers. +2. **FLUX keeps its own vocabulary** and this table stands as the permanent, + honest statement of what does and does not cross the seam. + +Either way the rule holds: no alignment claim without a CI gate that can fail. ## The four seams (paper §3.4) diff --git a/flux_spec/data/schema/flux-schema-0.5.0.json b/flux_spec/data/schema/flux-schema-0.5.0.json index ba90a3a..e63b6a0 100644 --- a/flux_spec/data/schema/flux-schema-0.5.0.json +++ b/flux_spec/data/schema/flux-schema-0.5.0.json @@ -1562,7 +1562,7 @@ "default": true } }, - "description": "Vocabulary aligned with FLUID's exposes[].policy.agentPolicy so seam-crossing policy comparison is field-to-field." + "description": "FLUX's own agent-policy vocabulary. It is NOT field-for-field identical to FLUID's exposes[].policy.agentPolicy and must not be compared as if it were: FLUX declares 5 members to FLUID's 13, FLUX's tokenBudget has no FLUID counterpart (FLUID splits it into maxTokensPerRequest and maxTokensPerDay), purposeLimitation is boolean here and a string in FLUID, and FLUX's use-case values are free strings where FLUID constrains them to a 12-value enum. See docs/reconciliation.md for the divergence table and what the seam validator can and cannot enforce because of it." }, "skillBinding": { "type": "object", diff --git a/flux_spec/data/schema/flux-schema-latest.json b/flux_spec/data/schema/flux-schema-latest.json index ba90a3a..e63b6a0 100644 --- a/flux_spec/data/schema/flux-schema-latest.json +++ b/flux_spec/data/schema/flux-schema-latest.json @@ -1562,7 +1562,7 @@ "default": true } }, - "description": "Vocabulary aligned with FLUID's exposes[].policy.agentPolicy so seam-crossing policy comparison is field-to-field." + "description": "FLUX's own agent-policy vocabulary. It is NOT field-for-field identical to FLUID's exposes[].policy.agentPolicy and must not be compared as if it were: FLUX declares 5 members to FLUID's 13, FLUX's tokenBudget has no FLUID counterpart (FLUID splits it into maxTokensPerRequest and maxTokensPerDay), purposeLimitation is boolean here and a string in FLUID, and FLUX's use-case values are free strings where FLUID constrains them to a 12-value enum. See docs/reconciliation.md for the divergence table and what the seam validator can and cannot enforce because of it." }, "skillBinding": { "type": "object", diff --git a/schema/flux-schema-0.5.0.json b/schema/flux-schema-0.5.0.json index ba90a3a..e63b6a0 100644 --- a/schema/flux-schema-0.5.0.json +++ b/schema/flux-schema-0.5.0.json @@ -1562,7 +1562,7 @@ "default": true } }, - "description": "Vocabulary aligned with FLUID's exposes[].policy.agentPolicy so seam-crossing policy comparison is field-to-field." + "description": "FLUX's own agent-policy vocabulary. It is NOT field-for-field identical to FLUID's exposes[].policy.agentPolicy and must not be compared as if it were: FLUX declares 5 members to FLUID's 13, FLUX's tokenBudget has no FLUID counterpart (FLUID splits it into maxTokensPerRequest and maxTokensPerDay), purposeLimitation is boolean here and a string in FLUID, and FLUX's use-case values are free strings where FLUID constrains them to a 12-value enum. See docs/reconciliation.md for the divergence table and what the seam validator can and cannot enforce because of it." }, "skillBinding": { "type": "object", diff --git a/schema/flux-schema-latest.json b/schema/flux-schema-latest.json index ba90a3a..e63b6a0 100644 --- a/schema/flux-schema-latest.json +++ b/schema/flux-schema-latest.json @@ -1562,7 +1562,7 @@ "default": true } }, - "description": "Vocabulary aligned with FLUID's exposes[].policy.agentPolicy so seam-crossing policy comparison is field-to-field." + "description": "FLUX's own agent-policy vocabulary. It is NOT field-for-field identical to FLUID's exposes[].policy.agentPolicy and must not be compared as if it were: FLUX declares 5 members to FLUID's 13, FLUX's tokenBudget has no FLUID counterpart (FLUID splits it into maxTokensPerRequest and maxTokensPerDay), purposeLimitation is boolean here and a string in FLUID, and FLUX's use-case values are free strings where FLUID constrains them to a 12-value enum. See docs/reconciliation.md for the divergence table and what the seam validator can and cannot enforce because of it." }, "skillBinding": { "type": "object",