Skip to content

Enforce the UK local binding fences at execution time (#760) - #801

Merged
juaristi22 merged 4 commits into
mainfrom
uk-local-binding-enforcement-760
Aug 28, 2026
Merged

Enforce the UK local binding fences at execution time (#760)#801
juaristi22 merged 4 commits into
mainfrom
uk-local-binding-enforcement-760

Conversation

@juaristi22

@juaristi22 juaristi22 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #760 (#495 increment 4, workstream G of #665).

The 7 review_required_before_binding fences in uk_local_target_census.json were declared data with no runtime enforcement — a fenced family could be bound by anyone who did not read the census. This PR adds the census's first runtime reader and makes the fence structural, reusing the existing reviewed-record machinery end to end (no new module, no new loader, no new test file).

What lands

  • Enforcement at the solve: require_adjudicated_uk_local_binding in local_rowwise.py, wired into solve_uk_rowwise_weights_under_doctrine immediately after the uniform-surface refusal — before any solve runs. It derives bound families structurally from the target surface (census family classifier × area_type), refuses a declared-vs-derived mismatch, and requires an in-force adjudication for every fence of every bound family. The solve signature is unchanged (no knobs).
  • Adjudication as a committed record: local_binding_adjudications.json, a plain schema-2 reviewed register loaded by the existing load_uk_reviewed_exclusion_register — same fields, validation, and correct-or-renew window semantics as the input-mass/QRF-tail/degenerate registers. Keys are census fence ids. Deliberately a separate per-concern file, not entries in calibration_measure_exclusions.json: that register removes measures and fails on any entry matching zero specs, so a fence-id entry there would red every national calibration run.
  • Manifest echo: the candidate runner validates before targets_bound, and both the --dry-run plan and rowwise_candidate_manifest.json carry a binding_adjudications receipt (register resource, evaluated_on, stood_on per family, dormant entries).
  • First adjudication (for review): census_disclosure_control_noise — accepts the stated basis (published disclosure-controlled counts as target values, never exact controls; measured cross-grain deltas E&W +105 / Scotland −554 / NI +3 accepted) and names constituency the winning grain for the rowwise arm. Window 2026-08-27 → 2027-02-27. The other 6 fences stay unadjudicated, so hmrc_income_by_area, equivalised_income, uc_households, and age_structure remain unbindable until reviewed — that is the point.
  • The census status_definitions promise text ("enforcement lands with the solve-doctrine increment") is retired for a description of the real enforcement; census regenerated, --check green.

Acceptance

  • Adversarial tests bind uc_households against the committed census+register and fail naming uc_unit_vs_household_grain; a runner-level test proves the build fails before the solve and still spools a failed Logbook row.
  • The register is enumerable and a referential-integrity test requires every entry to key a real census fence and cite its evidence anchor.
  • local_doctrine.py and its pinned test are byte-identical.
  • Verification: 177 tests green across the UK-scoped suite, ci_test_groups.py --verify ok, ruff clean, no US-lane tests touched or needed.

🤖 Generated with Claude Code

juaristi22 and others added 3 commits August 28, 2026 10:50
The rowwise doctrine solve now derives bound families structurally from
the target surface (census family classifier x area_type), refuses a
declared-vs-derived mismatch, and requires an in-force adjudication in
the committed schema-2 register local_binding_adjudications.json for
every fence of every bound family before any solve runs. The register
reuses the reviewed-exclusion machinery verbatim (correct-or-renew
windows); the dry-run plan and candidate manifest echo the adjudications
the run stood on; the first entry accepts census disclosure-control
noise and names constituency the winning grain for the rowwise arm.
The other 6 fences stay unadjudicated, so their families remain
unbindable until reviewed. Doctrine constants and their pinned test are
untouched.

Closes #760.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
María's review ruling: the census disclosure-control adjudication renews
on a 3-month cadence (2026-08-27..2026-11-27), not 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registering local_binding_adjudications.json in country_package.json
moves the UK bundle's spec_sha256; re-cut over main's own spec re-cuts
from the #757 certification and #750 WAS split, per the spec-identity
re-pin rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juaristi22
juaristi22 force-pushed the uk-local-binding-enforcement-760 branch from 3b3858c to 602afce Compare August 28, 2026 08:52
@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Automated review pass (Claude Code, high effort, diff only — no execution).

The fence fires, and I checked that against the branch rather than the diff alone, since a fence that cannot fire is the failure mode this PR exists to prevent: the census's families[*].adjudications key exists, all 7 ids in binding_fences (keyed fence_id) are covered by exactly those family lists, so the fence is neither vacuous nor rot-prone in that direction; the reused coerce_reviewed_exclusions requires the receipt field set exactly (reason, approved_by, adjudication, approved_on, expires_on), so a partial receipt is refused rather than half-accepted; a missing or short register raises instead of permitting; and the check runs before any solve. Reusing the reviewed-record machinery rather than adding a parallel one is the right call — it is the same guarantee, enforced once.

Three findings, none of which undermine that.

1. packages/microcosm-build/src/microcosm/build/uk/local_binding_adjudications.json:11 — the fence has a cliff edge on 2026-11-27

The only shipped adjudication expires 2026-11-27, and the solve path passes no now, so expiry resolves against wall clock. From 28 November, every rowwise doctrine solve and candidate build that binds census_households raises — with no warning window, no deprecation period, and a failure that will present as "the build broke overnight" to whoever hits it first.

An expiry is the right design; the cliff is the problem. Either surface remaining lifetime in the build record so it is visible before it bites, warn within some window of expiry, or thread an explicit now so the behaviour is testable and a build can be reproduced as-of a date. As it stands the first signal is a hard stop three months out.

2. tools/build_uk_rowwise_candidate.py:374 — the check is computed on every path but consumed on one

binding_adjudications = require_adjudicated_uk_local_binding(...) runs unconditionally, but the local is only read on the dry-run branch; the real run re-runs the identical check inside solve_uk_rowwise_weights_under_doctrine, and the manifest reads solve.binding_adjudications. So the census and register are parsed twice per build and the first result is discarded. Harmless today, but it leaves two call sites that must stay in agreement about what "adjudicated" means, which is the kind of drift this PR is otherwise closing.

3. uk_runtime/local_target_census.py:714 — the alias mutates the function it aliases

family_for_metric = _family_for_metric followed by assigning __doc__ mutates the shared function object, so the private classifier's own docstring is silently replaced rather than the alias being documented. A thin wrapper, or a module-level comment, keeps the original intact.

…th, honest alias

Both expiring adjudication registers — the local binding register and
the national calibration measure exclusions — now warn when an in-force
entry is within one week of expiry, so renewal is signalled before the
correct-or-renew failure lands (review finding 1, María's ruling). The
candidate runner computes the binding receipt only on the dry-run path;
the real path's single authority is the check inside the doctrine solve,
so the census and register are no longer parsed twice per build (finding
2). The public census classifier is a thin wrapper instead of an alias
that mutated the private function's docstring (finding 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator Author

All three findings addressed in 59e0168 (María's ruling on finding 1: warn one week out, on both expiring registers).

  1. Expiry cliff — both expiring adjudication registers now emit a UserWarning when an in-force entry is within one week of its expiry date: require_adjudicated_uk_local_binding (one warning per fence, naming the register, expiry date, evaluation date, and the renew remedy) and apply_uk_calibration_measure_exclusions (same shape, carrying the entry's tracking pointer). The warning fires on every solve/dry-run/apply in the window, so the first signal is a week of warnings, not an overnight stop. Warning-window tests with injected now cover both, plus silent-when-far assertions. (now was already threadable on both paths for reproducibility; the wall-clock default stands, matching the reviewed-exclusion convention.)

  2. Computed on every path, consumed on one — the runner now computes the binding receipt only on the dry-run branch (for the plan); the real path's single authority is the check inside solve_uk_rowwise_weights_under_doctrine, and the manifest keeps echoing solve.binding_adjudications. One parse per path, one meaning-bearer. The runner refusal test now exercises the real solve's own preflight (register emptied, targets_bound reached, solved never reached, failed Logbook row spooled).

  3. Alias mutating the aliased functionfamily_for_metric is now a thin wrapper with its own docstring; the private classifier keeps its original.

🤖 Generated with Claude Code

@juaristi22
juaristi22 merged commit c046b48 into main Aug 28, 2026
23 checks passed
MaxGhenis added a commit that referenced this pull request Aug 28, 2026
…nion (second application)

Both merged UK lanes re-pinned the UK spec digest on main; the union
with this train computes a fresh UK value (71054f53), keeps BE at the
train's 7062e38f, and leaves US unchanged (835b4d61) — all verified
against the live loader on the merged tree. Loader, gate-battery-pin,
UK content-identity, and country-bundle suites green on the union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce the UK local binding fences at execution time (#495 increment 4)

2 participants