# Change impact and dossiers When a package changes, reviewers need one question answered with evidence: which case outcomes changed, and which only changed their justification. A change dossier is the prepared answer to that question: a checked JSON record plus a readable rendering for reviewers. Environment: the dossier tools are Python scripts in a **source checkout** (**I**), run from the repository root; the lab case reads the fixture `docs/corpus/lab/fixtures/dossier-mini/` and writes only to `/tmp/lab-work/`. Marks follow the [topic legend](/corpus/#how-this-topic-marks-confidence). ## A lab case: the residential fee moves from 100 to 120 ### What changed in the model One number in one rule of the lab fees package `labparcels.fees`: the residential fee moves from 100 to 120. This is the after side: ```law rule FeeResidential strict { for p: Parcel; for k: ParcelKind; when labparcels.registry::registered(p) and labparcels.registry::kind_of(p, k) and k == labparcels.iface::residential; then fee_due(p, 120); } ``` ### Which answer change we expect The bank holds five fee scenarios, lowered to case files: ```text $ ls docs/corpus/lab/fixtures/dossier-mini/bank 01-fee-residential-ok--001.json 02-fee-commercial-ok--001.json 03-fee-garden-ok--001.json 04-fee-unregistered--001.json 05-fee-wrong-amount--001.json ``` Only the first scenario depends on the residential amount. It asks whether the fee due on residential parcel `p1` is **100**, and expects `TRUE_ONLY`: ```text evaluate truth(fee_due(entity_ref("urn:lab:parcel:p1"), 100)); expect truth_status == TRUE_ONLY; ``` (Excerpt from `parcels-fees/tests/01-fee-residential-ok.lawtest`.) So exactly one of five observations should move, and the other four (commercial, garden, unregistered, wrong amount) should stay as they were. ### What the report showed Section "3. Differences" of the rendered dossier, verbatim (lines 18–27 of `CHANGE-DOSSIER.en.md`): ```text $ sed -n '18,27p' docs/corpus/lab/fixtures/dossier-mini/CHANGE-DOSSIER.en.md Verdict `SEMANTIC-CHANGE` · rule structure: 0 added, 1 changed, 0 removed · 5 scenarios executed, 1 observations changed, 0 inputs rejected. Expected: **0** · outside declared scope: **0** · expected but missing: **0** · changed symbols without an executing scenario: **0**. ### Outcome changes (1) **Applicability and positions (1)** **🗓️ 2026-01-15** - **01-fee-residential-ok--001** (`docs/corpus/lab/fixtures/parcels-fees/tests/01-fee-residential-ok.lawtest:5`): **applies → undetermined**. ``` One rule changed, one outcome moved from established to undetermined, and there are no proof-only changes. All five scenarios ran, and no input was rejected. ### Why the answer became undetermined, not false The residential scenario asks for the **old** amount. After the change the package derives `fee_due(p1, 120)`; nothing derives `fee_due(p1, 100)` any more, and nothing states that it is false. So the question about 100 gets no answer on the after side (`TRUE_ONLY` → `NEITHER`, rendered as "applies → undetermined"). To confirm the new rate, a scenario should ask for 120. ### Expected or observed: declaring intent Read the `Expected: 0` above as detection without declared intent: the fixture change-set carries no `intent` block, so the one moved row is classified `observed`, not `expected` (`intentDeclared: false` in the dossier JSON). For a pre-agreed change, declare the delta instead — the same rebuild with this intent block added to a copy of the change-set: ```json "intent": { "declaredBy": "lab author: the rate move is meant to silence the old-amount scenario", "declaredOn": "2026-10-03", "expectedChanges": [ {"scenario": "01-fee-residential-ok--001", "before": "TRUE_ONLY", "after": "NEITHER"} ], "protectedGoals": [], "protectedScope": [] } ``` renders `Expected: **1**` and classifies the row `expected` (verified by rebuilding the copied change-set with `build_dossier.py --out-dir`). **(I)** What it justifies: a reviewer who sees `Expected: 1` and `outside declared scope: 0` can accept the change as exactly the agreed one; any other moved row would show up outside the declared scope. ### Commands that build the report Each step below **requires a source checkout** (**I**). Some tool messages are in Russian; an English reading follows each one. **Step 1.** Lower the scenarios again into a scratch bank; the fixture stays untouched: ```text $ python3 corpus/tools/change-dossier/lower_scenarios.py docs/corpus/lab/fixtures/parcels-fees/tests --program docs/corpus/lab/fixtures/dossier-mini/before-root.lawir.json --imports docs/corpus/lab/fixtures/dossier-mini/imports-context.json --output /tmp/lab-work/dossier-rerun/bank --manifest /tmp/lab-work/dossier-rerun/bank-manifest.json lower_scenarios: 5 дел из 5 файлов → /tmp/lab-work/dossier-rerun/bank ``` The message reads: "5 cases from 5 files". **Step 2.** Compare the fresh bank with the saved one before using it downstream, so that a lowering error fails here and not later: ```text $ diff -r /tmp/lab-work/dossier-rerun/bank docs/corpus/lab/fixtures/dossier-mini/bank && echo "bank trees identical" && cmp /tmp/lab-work/dossier-rerun/bank-manifest.json docs/corpus/lab/fixtures/dossier-mini/bank-manifest.json && echo "manifest bytes identical" bank trees identical manifest bytes identical ``` **Step 3.** Run both programs, each over its own closed world of the shared vocabulary plus the registry: ```text $ python3 corpus/tools/change-dossier/impact_worlds.py --before-root docs/corpus/lab/fixtures/dossier-mini/before-root.lawir.json --before-world docs/corpus/lab/fixtures/dossier-mini/world-labparcels.iface.lawir.json docs/corpus/lab/fixtures/dossier-mini/world-labparcels.registry.lawir.json --after-root docs/corpus/lab/fixtures/dossier-mini/after-root.lawir.json --after-world docs/corpus/lab/fixtures/dossier-mini/world-labparcels.iface.lawir.json docs/corpus/lab/fixtures/dossier-mini/world-labparcels.registry.lawir.json --scenarios docs/corpus/lab/fixtures/dossier-mini/bank --out /tmp/lab-work/dossier-rerun/impact.json --health /tmp/lab-work/dossier-rerun/health.json impact_worlds: SEMANTIC-CHANGE, изменилось 1 из 5; здоровы на обеих сторонах 5, пусты на обеих 0 ``` The message reads: "SEMANTIC-CHANGE, 1 of 5 changed; healthy on both sides 5, empty on both 0". One verdict, one changed row, every row healthy on both sides: ```text $ python3 -c "import json; r=json.load(open('/tmp/lab-work/dossier-rerun/impact.json')); print('verdict:',r['summary']['verdict'],'| changed:',r['summary']['changedScenarios'],'of',r['summary']['scenarios']); print('changed rows:',[x['name'] for x in r['scenarios'] if x.get('changed')]); h=json.load(open('/tmp/lab-work/dossier-rerun/health.json')); print('healthy both sides:',h['summary']['healthyBothSides'],'of',h['summary']['scenarios'])" verdict: SEMANTIC-CHANGE | changed: 1 of 5 changed rows: ['01-fee-residential-ok--001'] healthy both sides: 5 of 5 ``` **Step 4.** Confirm the rerun bytes match the committed producer outputs: ```text $ cmp /tmp/lab-work/dossier-rerun/impact.json docs/corpus/lab/fixtures/dossier-mini/impact.json && echo "impact bytes identical" && cmp /tmp/lab-work/dossier-rerun/health.json docs/corpus/lab/fixtures/dossier-mini/bank-health.json && echo "health bytes identical" impact bytes identical health bytes identical ``` **Step 5.** Read the change set: the unit, the world, and the three producer artifacts the dossier assembles from: ```text $ python3 -c "import json; c=json.load(open('docs/corpus/lab/fixtures/dossier-mini/change-set.json')); u=c['units'][0]; print('unit:',u['package'],'|',u['before']['label'],'=>',u['after']['label']); print('world root:',c['world']['root'],'| closure:',[p['name']+'@'+p['version'] for p in c['world']['closure']]); [print('producer:',p['tool'],p['role'],p['path']) for p in c['producers']]" unit: labparcels.fees | residential fee 100 (frozen edition) => residential fee 120 (draft) world root: labparcels.fees | closure: ['labparcels.iface@0.1.0', 'labparcels.registry@0.1.0'] producer: impact root docs/corpus/lab/fixtures/dossier-mini/impact.json producer: scenarios root docs/corpus/lab/fixtures/dossier-mini/bank-manifest.json producer: bank-health root docs/corpus/lab/fixtures/dossier-mini/bank-health.json ``` **Step 6.** Rebuild the dossier from the same bytes and compare hashes; the committed dossier passes: ```text $ python3 corpus/tools/change-dossier/build_dossier.py docs/corpus/lab/fixtures/dossier-mini/change-set.json --check change-dossier: --check OK — ядро sha256:02e6fb3a5e1fa86be8f617ecae99e9770e8a43857309d8e8bd3e05eb56f74f9e, пакет sha256:9c0f5a8076ed7c8fd569eca2e48ae9098e78330253339f5b014abd741ad8f1ff ``` The message reads: "`--check` OK — core sha256:…, package sha256:…", the two hashes of the rebuilt dossier. ## The two artifacts A dossier run starts from a change set (`law.change-set/0.1`): state pairs, the world each side runs in, the introduction regime, the declared intent, and the producer artifacts that already computed each part. It ends with a dossier (`law.change-dossier/0.1`) plus its human-readable print. The checked JSON dossier is the record; the rendered pages present that record. **(I)** ## The assembler executes nothing The builder reads the producer bytes, verifies each producer hash against the change set declaration, and lays the already-computed results out by report section. A section without a producer prints as missing, never as silent. Rebuilding from the same bytes must yield the same hashes, and the `--check` flag verifies exactly that. Observed usage: ```text $ python3 corpus/tools/change-dossier/build_dossier.py --help usage: build_dossier.py [-h] [--out-dir OUT_DIR] [--check] [--pin] [--commit COMMIT] [--reproduction {reproduced,not-attempted,failed}] [--reproduction-note REPRODUCTION_NOTE] [--binary BINARY] [--source-base-url SOURCE_BASE_URL] [--include-ru] [--strict] change_set ``` Typical forms, quoted from the same help text: ```text python3 corpus/tools/change-dossier/build_dossier.py [--out-dir DIR] [--commit ] [--reproduction reproduced|not-attempted|failed --reproduction-note "…" [--binary "…"]] python3 corpus/tools/change-dossier/build_dossier.py --check # пересборка в память и байтовая сверка python3 corpus/tools/change-dossier/build_dossier.py --pin # проставить sha256 продюсерам (явная правка входа) ``` The two Russian comments read: `--check` rebuilds in memory and compares bytes; `--pin` writes the sha256 of each producer into the change set (an explicit edit of the input). Use `--pin` when producer bytes are final and their hashes must be recorded in the change set input itself. **(I)** ## The impact producer: before and after worlds The impact report runs both programs, each over its own closed world: a root plus the compiled world on each side, evaluated against a directory of JSON cases. It emits two files: the impact report and a bank-health file of the same shape. An outcome counts as unchanged only when the row is healthy on both sides. Observed usage: ```text $ python3 corpus/tools/change-dossier/impact_worlds.py --help usage: impact_worlds.py [-h] --before-root BEFORE_ROOT [--before-world [BEFORE_WORLD ...]] --after-root AFTER_ROOT [--after-world [AFTER_WORLD ...]] --scenarios SCENARIOS --out OUT --health HEALTH ``` The tool help records a cautionary case: a report that ran the root without its world failed most cases identically on both sides, which is not an observation of any kind. Always pass the world inputs. **(I)** ## Review presentation and the acceptance check The pull-request producer takes two prepared checkouts plus the world builder both sides share, then routes the scenario lowering, the impact run, the assembler, and the checker. Observed usage: ```text $ python3 corpus/tools/change-dossier/pr_dossier.py --help usage: pr_dossier.py [-h] --base BASE --head HEAD --lawc LAWC --package-tool PACKAGE_TOOL --out OUT [--package PACKAGE] [--run-url RUN_URL] [--source-base-url SOURCE_BASE_URL] [--include-ru] ``` The review comment separates changed outcomes from proof-only changes, groups changed symbols by topic, and folds wide tables and full symbol ids under technical details. English is the default; `--include-ru` adds the folded Russian section. Optional display labels map scenario ids to short bilingual titles without changing any test or legal result. **(I)** The acceptance-bundle check admits a release dossier only when the exact journal-outcome comparison and the structural compiled-form comparison refer to the same pinned edition worlds and the same historical dossier. It rebuilds the export from the source journal and recomputes both reports before accepting anything. Observed usage: ```text $ python3 corpus/tools/change-dossier/verify_serve_c4_bundle.py --help usage: verify_serve_c4_bundle.py [-h] --comparison COMPARISON --world-semdiff WORLD_SEMDIFF --expect-count EXPECT_COUNT --expect-changed EXPECT_CHANGED --expect-structural-package EXPECT_STRUCTURAL_PACKAGE --out OUT ``` The expectation flags pin the case count, the changed count, and the package under comparison, so a one-row probe can never pass as full-bank acceptance. For example, the documented release run expects 101 cases with 0 changed for the package `kz.corpus.ogpovts`, while the bonus-malus bank run expects 101 cases with 26 changed for `kz.corpus.bonus_malus`. **(I)** ## What to read next - [Command-line reference](/cli/) for the evaluator and world commands the producers build on. - [Schema catalog](/protocols/schemas/) for the change-set and dossier formats.