Contract Fix — cut_manifest_piece_schema_v1 7-field + VERIFY_MARK Axis D (2026-05-27)
04 — Contract Fix (mig: dieu39_unit_kind_fix)
Apply mode
Single transaction, COMMITTED in one psql script. 2 x CREATE OR REPLACE FUNCTION.
BEGIN
CREATE FUNCTION -- fn_cut_mark_staged_file
CREATE FUNCTION -- fn_iu_verify_mark
COMMIT
Script md5 = ef6b9e52337f1e90aea900358225dd83 (file /tmp/dieu39_unit_kind_fix.sql, 313 lines).
Diff at a glance
fn_cut_mark_staged_file — cut_manifest_piece_schema_v1 widened 6 to 7 fields
-- NEW block inserted into the per-piece validation FOR loop:
-- NEW: unit_kind required and must be in live dot_config vocab.unit_kind.*
IF v_uk_val IS NULL OR btrim(v_uk_val) = '' THEN
RAISE EXCEPTION 'fn_cut_mark_staged_file: piece[%].unit_kind is required (cut_manifest_piece_schema_v1)', v_idx;
END IF;
PERFORM 1 FROM public.dot_config WHERE key = 'vocab.unit_kind.' || v_uk_val;
IF NOT FOUND THEN
RAISE EXCEPTION 'fn_cut_mark_staged_file: piece[%].unit_kind % not in dot_config vocab.unit_kind.* (cut_manifest_piece_schema_v1)', v_idx, v_uk_val;
END IF;
Variable v_uk_val text declared at top of DECLARE block, assigned from v_piece->>'unit_kind' in the same line group as other piece-field reads.
All other behavior of fn_cut_mark_staged_file is identical:
- status guard (
v_req.status NOT IN ('copied','mark_rejected')) unchanged - transition
mark_in_progress->fn_iu_op_mark_filecall -> transitionmarked-> signal — all unchanged - idempotency key
cut-mark-<cut_request_id>-md5(p_pieces::text)— implicitly tightened since new schema rejects bad pieces before they ever reach the alias - return shape unchanged (
piece_schema_validated='cut_manifest_piece_schema_v1')
md5: 67721b42a55315858e3c377654c2a5b1 -> e85065acb9996623e0ef1f654d991df6.
fn_iu_verify_mark — Axis D added
-- NEW: Axis D — every piece.unit_kind non-empty AND in live dot_config vocab.unit_kind.*
SELECT count(*) INTO v_uk_total FROM jsonb_array_elements(v_pieces);
SELECT count(*) INTO v_uk_missing FROM jsonb_array_elements(v_pieces) p
WHERE (p->>'unit_kind') IS NULL OR btrim(p->>'unit_kind') = '';
SELECT count(*) INTO v_uk_in_vocab FROM jsonb_array_elements(v_pieces) p
WHERE (p->>'unit_kind') IS NOT NULL
AND btrim(p->>'unit_kind') <> ''
AND EXISTS (SELECT 1 FROM public.dot_config dc
WHERE dc.key = 'vocab.unit_kind.' || (p->>'unit_kind'));
v_axis_d := (v_uk_missing = 0) AND (v_uk_in_vocab = v_uk_total);
IF NOT v_axis_d THEN
v_problems := v_problems || ARRAY[
'Axis D: unit_kind compatibility failed (total='||v_uk_total
||', missing='||v_uk_missing
||', in_vocab='||v_uk_in_vocab
||') — every piece.unit_kind must exist in dot_config vocab.unit_kind.*'
];
END IF;
Return shapes updated to include axis_d_ok:
- success:
{'ok':true,'verdict':'approved','axis_a_ok':true,'axis_b_ok':true,'axis_c_ok':true,'axis_d_ok':true} - rejected: includes
axis_d_okfield in rejection JSON metadataupdated on approval to includeverify_mark_axis_d:true
Structured-verdict pattern is preserved (no hard RAISE — failures surface as problems[] per existing axes).
md5: 04e5191c430a142712bfe63089863d44 -> 1db15847b1c48e3b86568b712a15cfd6.
cut_manifest_piece_schema_v1 — formal contract (post-fix)
Required per piece (7 fields):
| Field | Type | Constraint |
|---|---|---|
local_piece_id |
text | non-empty, unique within manifest |
content_text |
text | non-empty |
canonical_address |
text | non-empty |
source_position |
text matching ^[0-9]+$ |
integer >= 1 |
piece_role |
text | non-empty |
section_type |
text | non-empty AND dot_config vocab.section_type.<v> exists |
unit_kind |
text | non-empty AND dot_config vocab.unit_kind.<v> exists -- NEW |
Optional per piece (1 field):
| Field | Type | Constraint |
|---|---|---|
parent_local_id |
text or null | if not null, must resolve to another piece's local_piece_id in the same manifest (verify_mark Axis C) |
Manifest-level (verify_mark only):
| Field | Constraint |
|---|---|
source_position density |
dense from 1, no holes (Axis A) |
manifest_digest |
32-hex |
coverage_proof.covered_bytes |
equals manifest.source_bytes |
per-piece unit_kind |
all in dot_config vocab.unit_kind. (Axis D)* -- NEW |
Refusal contract
| Boundary | Trigger | Error pattern |
|---|---|---|
MARK call (fn_cut_mark_staged_file) |
piece.unit_kind missing | piece[N].unit_kind is required (cut_manifest_piece_schema_v1) |
MARK call (fn_cut_mark_staged_file) |
piece.unit_kind not in vocab.unit_kind.* | piece[N].unit_kind <V> not in dot_config vocab.unit_kind.* (cut_manifest_piece_schema_v1) |
VERIFY_MARK call (fn_iu_verify_mark) |
any piece fails Axis D | structured: {ok:false, verdict:'rejected', axis_d_ok:false, problems:[...'Axis D: ...']} |
All errors are indexed (include piece index N) for caller convergence.
Boundary placement rationale
The MARK schema gate lives in fn_cut_mark_staged_file (the operational wrapper), NOT in fn_iu_op_mark_file (the governance-pinned alias whose md5 must remain ffaa47fff7a906d93060141661080cd4). This preserves the alias contract while tightening the operational pipeline.
VERIFY_MARK Axis D is the secondary defense: even if a future caller bypasses fn_cut_mark_staged_file and inserts a manifest directly into iu_staging_payload (which the data model does NOT prevent), the operator approval path via fn_iu_verify_mark will catch it before lifecycle_status='approved'.
Rollback path
-- one-shot recovery (loses unit_kind gate but is non-destructive)
BEGIN;
-- Re-CREATE prior bodies:
-- fn_cut_mark_staged_file md5 67721b42a55315858e3c377654c2a5b1 (6-field schema)
-- fn_iu_verify_mark md5 04e5191c430a142712bfe63089863d44 (3 axes)
-- Sources: /tmp/pre_dieu39_unit_kind_fix_20260527.sql (pg_dump, 921 MB, md5 2d21965b...)
COMMIT;
The pg_dump backup contains the verbatim prior CREATE OR REPLACE FUNCTION statements in the same single TX shape.
Forbiddens honored
- No alias body changed (5/5 md5 pinned: c5d556bc / 66b813e5 / ffaa47ff / bf20bd19 / ac61dade)
- No
dot_configmutation (no vocab widen, no mapping row) - No table DDL
- No
pg_cron - No
production_documentstouch - No Qdrant touch
- No event_outbox mutation
- No gate flip