Skip to content

feat: close six authoring and contract tickets across BReg and Casework - #1271

Merged
jeremi merged 9 commits into
mainfrom
feat/explain-contract-and-authoring-fixes
Sep 21, 2026
Merged

jeremi merged 9 commits into
mainfrom
feat/explain-contract-and-authoring-fixes

Conversation

@jeremi

@jeremi jeremi commented Sep 21, 2026

Copy link
Copy Markdown
Member

Six ranked authoring and contract tickets, one commit each, in the order they were ranked.

Commit Ticket What it fixes
feat(casework): read a policy digest without writing a package #1260 Inspecting a policy digest required writing a package tree
fix(breg): discriminate the two route shapes in explain routes #1257 explain routes mixed two record shapes with no discriminator
fix(breg): write starter projects as block-style YAML #1258 bregctl init --template wrote JSON into .yaml files
fix(casework): refuse a source description pinning an unresolvable review policy #1256 A broken review.policyId binding dead-lettered silently at runtime
feat(breg): attribute module-declared entities and their members #1259 A compiled entity recorded nothing about which module declared it
feat(breg): publish the explain payloads as a versioned wire contract #1248 Explain payloads had no declared shape, no version, and no drift gate

Plus a final docs(cli): commit updating the CLI publication record, which is a separate owning area (docs/site/).

Consequences worth reading before merge

#1259 moves the revision digest for module-using projects. CompiledRegistry.revision digests the whole generated artifact map, which always includes compiled/effective-model.json, and that file serializes CompiledEntity verbatim. Adding sourceModule and moduleOrigins therefore changes the revision for any project that composes modules, even though every committed generated baseline is byte-identical — no fixture commits compiled/effective-model.json, and the generate <selector> path never writes it. check-generated.sh cannot see this. An operator who records or diffs the revision reported by bregctl generate will see it move on upgrade. Module-free projects are unaffected: both fields are omitted when empty, so those serialize byte-identically.

There is a sharper consequence than an operator diffing a revision. A Casework BReg source description pins its registry revision: sourceRevision is read into expected_registry_revision (crates/registry-casework-breg/src/config.rs:340), and the adapter compares it against the live value on every metadata read, returning SourceAdapterError::BindingMoved on a mismatch (crates/registry-casework-breg/src/lib.rs:305). For a module-using deployment, recompiling the registry on this engine moves that revision, so an existing, working source binding starts refusing at runtime. Nothing offline catches it first: caseworkctl check does not re-derive sourceRevision. Those descriptions need repinning as part of the upgrade.

#1256 re-derives all four assertions caseworkctl source add makes, not three. The named reviewKinds entry exists, its purpose is approval, its contextStrategy is source, and exactly one reviewProducers[] entry admits this source's id for the pinned kind. The producer assertion carries two distinct refusals because the two faults are not the same fault. No admitting producer breaks the binding the way a missing policy does: the runtime forbids a submission whose producer covers neither the submitted kind nor the subject's source namespace, and it dead-letters. More than one admitting producer is not a runtime fault at all, since the runtime resolves a producer by actor identity; it is source add that refuses to choose between two, so a project in that state can no longer be repinned by the command that wrote its binding.

#1256 claims only what it can prove. The new check is offline. It compares the pinned description against the policy on disk right now; it does not re-derive sourceRevision, so it cannot detect drift in the BReg registry.yaml the description was compiled from. Every refusal names the pinned sourceRevision and is worded to say the binding is broken as pinned, never that the pin has been verified current.

#1256's refusal text does not currently reach the author, and that is pre-existing. caseworkctl check classifies any anyhow error that is not an io failure, a RuntimeConfigError, a ConfigLoadError or a ConfigError into a catch-all bucket, so all five new refusals are collapsed to error[caseworkctl.refused] runtime: The Casework command was refused because an authored input did not satisfy its contract. with next: Correct the unavailable runtime dependency, then retry. The --format json diagnostic drops the detail too. Verified on this head by breaking a policyId and running the command; the classifier at crates/registry-caseworkctl/src/lib.rs:447-453 and :481-486 is untouched by this branch. The refusal and the non-zero exit are correct and the wording is right in the source, but an author reading stdout is pointed at infrastructure rather than at their YAML. Filed as #1272 rather than scoped here, since fixing it changes the message of every caseworkctl refusal.

#1256 fixes an example that carried the bug it demonstrates. products/casework/examples/multi-stage-routing-clocks pinned regional-review from both source descriptions while declaring no reviewKinds at all. The matching casework.id was a naming coincidence, not a resolution rule. The example gains the requester access profile, the regional-review kind, and the producer that binds them.

#1258's YAML conversion is semantically identical, verified by a type-preserving round-trip against the previous bytes. Scalars YAML would otherwise resolve to another type are quoted: '0.000' and '100.000' would become floats, '2026-01-01' a date, and an empty authorizationConditions would become null.

CLI publication record

#1260 is the only change on this branch that touches public command content; across all commits the only clap attribute changes in the workspace are the two in crates/registry-caseworkctl/src/lib.rs. The delta to caseworkctl package:

  • --output is no longer always required, and its help gains "Required unless --dry-run"
  • new --dry-run option
  • new mutually_exclusive constraint over the two

docs/site/src/data/cli-reference.yaml is updated with the digests npm run cli-reference:digest reports after that review. reviewed_source_version stays 0.32.0.

#1248: what the explain contract does and does not promise

Every explanation object now opens with apiVersion: registry.registrystack.org/breg-explain/v1alpha1 and a kind naming one of
eight shapes, each authored as a JSON Schema under
products/breg/contracts/explain/.

The envelope is inserted into the serialized explanation rather than declared
on SuccessReport, because explanation is a slot project lock and
generate also populate. Their output is unchanged.

Consumer-visible: every explain payload gains two keys. A caller
asserting an exact key set sees apiVersion and kind alongside what it
expected; explain_routes_preserves_action_free_output_shape is the one
in-tree example and is updated.

Additive on the wire. The runtime change is a single insertion at one
call site: two keys added to an already-serialized object, which cannot
alter, drop or reorder anything else. A consumer outside this repository
re-captured the explain payloads it reads on this head, seven of the eight
kinds across two projects, and reports the wire diff is exactly the two added
keys. That is that consumer's own gate result, not re-run here, and it does
not cover AccessPreview.

Pinned is not the same as sealed everywhere, and that is deliberate. A key
bregctl builds field by field is pinned: required, typed, sealed with
additionalProperties: false. A key bregctl produces by serializing a
compiled-model value straight through is declared opaque, typed only as an
object or array. A passthrough's shape follows its Rust type and can change
with no line of bregctl changing, so pinning it would assert a contract
nothing enforces. The README names every opaque node. The ones worth knowing
before you rely on this:

  • EventsExplanation is opaque past the envelope. deliveries is a single
    raw passthrough of CompiledEventDeliveryInventory, so this schema
    promises little beyond the envelope today.
  • RoutesExplanation's entity-route half is opaque except the kind
    discriminator explain routes returns a union of two record shapes with no discriminator #1257 added. Only the action-route half is hand-rolled and
    fully pinned.
  • ActionsExplanation.handler.possibleWrites and .handler.limits are
    opaque, while the equivalent ChangeRequestsExplanation.requests[].planner
    fields are pinned in full, because that path builds them field by field.
  • fieldType is opaque everywhere it appears, even inside otherwise sealed
    objects.

Three branches are pinned from source but unexercised by any tracked
fixture
, recorded in the README rather than hidden: the WASM handler branch
in ActionsExplanation, the "clear" field-mutation kind in
ActionsExplanation and ChangeRequestsExplanation, and revisionKind on a
revisions route.

The gate, crates/registry-bregctl/tests/explain_contract.rs, runs the real
binary over 11 fixtures and 2 access-review scenarios, 79 payloads, and
validates each against its schema file. check-contracts.sh runs it, and the
9 new files are registered in artifact-inventory.yaml.

Verification

Gate Result
cargo fmt --check clean
cargo check --locked --workspace --all-targets exit 0, zero warnings
cargo clippy --workspace --all-targets -- -D warnings exit 0
cargo test --locked -p registry-breg --features postgres-test --lib 419 passed, 0 failed, against a real PostgreSQL 17.11
products/casework/scripts/check-checkpoint.sh exit 0
products/breg/scripts/check-contracts.sh exit 0, including the 8 new explain-contract tests and 90 python tests
cargo test --locked -p registry-bregctl every suite green, 8 passed in explain_contract.rs

The registry-breg suite is run with postgres-test and a live database on
purpose. Under --workspace, cargo feature unification enables
registry-breg/postgres-test through registry-casework, so a bare
cargo test --workspace fails 8 tests with BREG_TEST_DATABASE_URL is required unless a database is present. CI shards per package and never hits
this. The condition is pre-existing and untouched by this PR; it is recorded
here so the next person does not read those failures as a regression, and
filed as #1273.

The casework checkpoint gate needed a macOS-only workaround to run at all: a
directly executed caseworkctl aborts because the FIPS build of AWS-LC is a
dylib with no rpath on Apple targets. That is unrelated to this PR and is
filed as #1270. The gate result above is a genuine pass, not a skip.

Not in this PR

#1261 (released professional-review example carries no reviewKinds) is not code. It is release sequencing: it resolves itself if #1189 is in the next cut. Nothing here addresses it, and nothing here blocks it.

Review round, 2026-09-22

Seven threads from the review connector. Four were real defects in this
branch and are fixed here, in two commits.

fix(casework): accept several producer identities for one source binding
withdraws the exactly-one reviewProducers[] rule that the #1256 commit
added to check_source_review_binding. It was wrong. ReviewRuntime::producer_for_actor
resolves a producer by the authenticated actor's profile, issuer, and subject,
never by uniqueness, so two identities admitting one source id for one pinned
kind are a working failover pair. Mirroring source add's count alongside its
filter turned that valid project into a refusal on check, explain, and
package at once. The filter is still mirrored; the count is not. source add
keeps its own rule, because it has to choose the credentials it pins. The test
asserting the refusal is now its inverse.

This corrects the fourth assertion as described in this PR's own body: zero
admitting producers is still a genuine dead letter and still refuses, but more
than one was never a runtime fault.

fix(breg): correct the explain contract's optional passthroughs and key policy
fixes three contract defects, two of which meant a valid payload failed the
gate published in this same PR:

  • ModelExplanation required package and manifestProjection to be objects.
    Both are Option on CompiledRegistry, and explain_model serializes them
    as null for a project that declares neither. Now ["object", "null"].
  • AccessExplanation typed claimContractError as an object.
    AuthorityInventoryError is a value-free enum under
    rename_all = "snake_case", so it serializes as a bare string: the schema
    rejected the one payload that field exists to expose. Now pinned as its
    eight-member string vocabulary plus null, which moves it out of the
    opaque list.
  • The compatibility promise said adding an optional key to a pinned object
    needs no apiVersion bump, while every pinned object seals itself with
    additionalProperties: false. A consumer holding this version's schema
    would reject exactly the addition the policy called compatible. Adding any
    key to a pinned object is now a bump, and the paragraph says why.

The remaining three threads are recorded rather than fixed, each for a stated
reason: #1277 (module provenance omits attachment slots, pre-existing and
in the compiler rather than this surface), #1278 (ActionsExplanation does
not tie backend fields to handler.kind, and four branches the gate pins from
source but never replays; the oneOf wants the WASM fixture to land with it),
and #1272 (every authoring refusal is flattened to caseworkctl.refused
across 79 bail! sites, which belongs at the classifier, not at one call site).

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-21T19:10:23.021146Z 825036d New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57a303a8b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-breg/src/model.rs
Comment thread products/breg/contracts/explain/ActionsExplanation.schema.json
Comment thread products/breg/contracts/explain/ModelExplanation.schema.json
Comment thread products/breg/contracts/explain/AccessExplanation.schema.json Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

"serversBuiltWithoutTheFeature":
"load the package and fail each invocation with a typed handler failure",

P2 Badge Report featureless WASM servers as load-time refusals

For a package containing a WASM action handler, a server built without the wasm feature cannot load the package and defer failure until invocation as this explanation claims. load_package re-derives the package through compile_project_with_assets, and the featureless compile_handler path emits action.handler.wasm_build_unsupported, causing package derivation to fail during load. Because this adopter-facing statement is also pinned as a schema constant, it gives operators the wrong deployment compatibility guarantee; describe the load-time refusal instead.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-caseworkctl/src/project.rs Outdated
Comment thread crates/registry-caseworkctl/src/project.rs
Comment thread products/breg/contracts/explain/README.md Outdated
Inspecting a Casework policy's digest and file list required running
`caseworkctl package` against a real output directory, so a caller who
only wanted to compare digests had to create and clean up a package tree.

Add `--dry-run`, mutually exclusive with `--output` through clap so the
misuse is a usage error rather than a runtime refusal. It runs every
validation and refusal the written path runs, reports the same
`policyDigest` and `files`, sets `dryRun: true`, and omits `output`
entirely. The shared work moves into `compute_package` so the two paths
cannot drift. The checkpoint gate now asserts that equivalence.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`bregctl explain routes` emits entity routes and action routes in one
array. They share no field, and nothing on a record said which shape it
was, so the obvious consumer that reads `entityId` on every route
crashed on any project declaring an immediate action. Action routes sort
last, so the failure landed on the final element and read as truncated
output rather than a second record shape.

Every record now carries `kind`, either `entity` or `action`. It is
distinct from `actionRouteKind`, which says which action route a record
is rather than which shape it has; `action` also covers the
target-conditions routes sharing the array. The field lives in the
explain payload, not on `CompiledRoute` or `CompiledActionRoute`, which
are `deny_unknown_fields` types that round-trip through package
inventories.

This also removes an early return that skipped the whole pass for
action-free projects, which would have left those records with no
discriminator at all.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`bregctl init --template <id>` shipped its four starters as
pretty-printed JSON in files named `.yaml`. They parsed, since YAML is a
superset of JSON, but the first thing an adopter opened looked nothing
like the commented YAML that plain `bregctl init` and `--from
publicschema` already write, and nothing in it invited editing.

Convert all sixteen starter files to block style: one key per line,
sequences as `-` items, no flow collections at any depth. Each
`registry.yaml` gains a short header naming what the file is and calling
out `canonicalBaseIri` as a reserved example.org stand-in to replace.

The conversion is semantically identical, verified by a type-preserving
round-trip against the previous bytes. Scalars that YAML would otherwise
resolve to another type are quoted: `'0.000'` and `'100.000'` would
become floats, `'2026-01-01'` a date, and an empty
`authorizationConditions` would become null.

`starter_projects.rs` reads these files as YAML rather than JSON, since
they are no longer both.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…view policy

A BReg source description pins `review.policyId`, but nothing offline
confirmed that id still resolves against the project's declared
`reviewKinds` and `reviewProducers`. `validate_breg_source_description`
only checks the description's shape against the closed adapter contract
and forwards the id unchecked, so removing, renaming, or repurposing a
`reviewKinds` entry, or dropping the producer that admits the pair, left
the binding broken with no signal at authoring time.

At runtime the consequence is silent. `enqueue_submission` copies the
pinned id verbatim into the review request, the runtime finds no matching
policy and refuses with 403, and `terminal_submission_error` treats any
400-499 except 429 as terminal: the row is set to `failed` with
`remote-refused` and never retried. The review dead-letters permanently
and the author is told nothing.

`check_source_descriptions` now re-derives the four assertions
`caseworkctl source add` makes when a binding is first written: the named
policy exists, its purpose is approval, its contextStrategy is source,
and exactly one `reviewProducers[]` entry admits this source's id for the
pinned kind. Because `check`, `explain`, `simulate`, `package`, and
`package --dry-run` all route through it, every one of them refuses the
same broken binding.

The producer assertion carries two distinct refusals because the two
faults are not the same fault. No admitting producer breaks the binding
the way a missing policy does: the runtime forbids a submission whose
producer covers neither the submitted kind nor the subject's source
namespace, and it dead-letters as above. More than one admitting producer
is not a runtime fault at all, since the runtime resolves a producer by
actor identity; it is `source add` that refuses to choose between two, so
a project in that state can no longer be repinned by the command that
wrote its binding.

The check is offline and claims only what it can prove. It compares the
pinned description against the policy on disk right now; it does not
re-derive `sourceRevision`, so it cannot detect drift in the BReg
`registry.yaml` the description was compiled from. Every refusal names
the pinned `sourceRevision` and is worded to say the binding is broken as
pinned, never that the pin has been verified current.

The `multi-stage-routing-clocks` example carried the bug it now
demonstrates: both its source descriptions pinned `regional-review` while
the project declared no `reviewKinds` at all, and the matching
`casework.id` was a naming coincidence, not a resolution rule. It gains
the requester access profile, the `regional-review` kind, and the
producer that binds them.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
A compiled entity recorded nothing about where it came from. For a
project that composes modules, `bregctl explain` showed an entity
identical to one declared at the project root, so an author reading the
compiled output could not tell which module owned an entity, nor which
module contributed an individual field, constraint, hook, index, access
profile, selector profile, read path, or derived relation.

`CompiledEntity` now carries `sourceModule`, the module that declared the
entity, and `moduleOrigins`, one map per id-keyed collection recording
which module contributed each id. Attribution follows declaration: the
declaring module owns the entity and everything it declares, and an
extending module owns exactly the ids it appends. An id absent from a map
came from the project root.

Both fields are omitted when empty, so a project that uses no modules
serializes byte-identically to before.

The compiler previously threaded origin information through separate
parameters; they are folded into one `CollectedOrigins` value passed
through `collect_entities`, `apply_extensions`, and `compile_entities`.

One consequence is not visible in any committed artifact and is worth
stating plainly. `CompiledRegistry.revision` digests the whole generated
artifact map, which always includes `compiled/effective-model.json`, and
that file serializes `CompiledEntity` verbatim. The revision therefore
changes for a module-using project even though every committed generated
baseline is byte-identical, because no fixture commits
`compiled/effective-model.json` and the `generate <selector>` path never
writes it. An operator who records or diffs the revision reported by
`bregctl generate` for a module-using project will see it move on
upgrade.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`bregctl explain` prints JSON that adopters already parse, but nothing
described its shape and nothing stopped a refactor from renaming a key.
A reader could not tell which payload they were holding, nor which keys
were safe to depend on.

Every `explanation` object now opens with an envelope,
`apiVersion: registry.registrystack.org/breg-explain/v1alpha1` and a
`kind` naming one of eight shapes, and each shape is authored as a JSON
Schema under `products/breg/contracts/explain/`.

The envelope is inserted into the serialized explanation rather than
declared on `SuccessReport`, because `explanation` is a slot that
`project lock` and `generate` also populate; their output is unchanged.

The contract distinguishes two kinds of key. A key bregctl builds field
by field is pinned: required, typed, and sealed with
`additionalProperties: false`. A key bregctl produces by serializing a
compiled-model value straight through is declared opaque, typed only as
an object or array, because its shape follows that Rust type and can
change with no corresponding line changing in bregctl. Pinning a
passthrough would assert a contract nothing enforces. The README names
every opaque node and the three branches no tracked fixture exercises.

`tests/explain_contract.rs` keeps the contract honest. It runs the real
binary over eleven fixtures and two access-review scenarios, 79 payloads
in total, and validates each against its schema file, so drift between
`explain_*` and the authored contract fails here rather than at an
adopter. `check-contracts.sh` runs it.

One consumer-visible consequence: every explain payload gains two keys.
A caller asserting an exact key set, as
`explain_routes_preserves_action_free_output_shape` did, now sees
`apiVersion` and `kind` alongside what it expected.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
`caseworkctl package` is the only public command content this branch
changes: `--output` is no longer unconditionally required and its help
now says "Required unless --dry-run", `--dry-run` is new, and the two
are mutually exclusive. Those are the only clap attribute changes in the
workspace across all six commits.

The catalog and content digests are what `npm run cli-reference:digest`
reports after that review. `reviewed_source_version` stays `0.32.0` and
`last_reviewed` stays `2026-09-21`, because this is a content review of
an unreleased change, not a version bump.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The source-description review check refused a project whose casework.yaml
declares more than one reviewProducers[] entry admitting a source id for a
pinned review kind. A failover integration beside a primary one is exactly
that shape, and the runtime supports it: ReviewRuntime::producer_for_actor
resolves a producer by the authenticated actor's profile, issuer, and
subject, never by uniqueness, so both identities submit under the same
policy and neither shadows the other.

Only `caseworkctl source add` needs exactly one, because it has to choose
the credentials it writes into the description, and it keeps its own rule.
Mirroring that count into check, explain, and package turned a working
project into a refused one on all three commands.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ey policy

Three defects in the published explain contract, all of which would refuse
a valid payload or promise something the schemas do not keep.

ModelExplanation required `package` and `manifestProjection` to be objects.
Both are `Option` on CompiledRegistry (model.rs), compilation succeeds with
a finding when a project omits them, and `explain_model` serializes them as
null, so a valid `bregctl explain model` response failed the gate.

AccessExplanation typed `claimContractError` as an object. AuthorityInventoryError
is a value-free enum carrying `rename_all = "snake_case"`, so it serializes
as a bare string: the schema rejected the one payload that field exists to
expose. It is now pinned as its eight-member string vocabulary plus null,
which moves it out of the opaque list.

The compatibility promise allowed adding an optional key to a pinned object
without an apiVersion bump, but every pinned object seals itself with
`additionalProperties: false`, so a consumer holding one version's schema
rejects a response carrying the next version's new key. Adding a key to a
pinned object is a bump.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
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.

1 participant