Data Model: Meta.json Fail-Closed Read Routing

No new persisted data. This mission reshapes the decode seam and the comparator; the "entities" are code contracts and the read-site taxonomy.

Seam tiers (after the mission)

TierSymbol (target home)SignatureOwnsI/O
L1kernel.meta_decode.decode_meta (NEW)`(raw: str \bytes, *, on_malformed="raise") -> dict \None`
L2specify_cli.mission_metadata._parse_meta_text (re-expressed) + parse_meta_file (NEW public)`(path: Path, *, on_malformed, encoding) -> dict \None`file open + empty→benign short-circuit
L3specify_cli.core.paths.load_meta_fail_closed (re-expressed)(feature_dir: Path) -> dict (raises on corrupt, None/{} on missing per contract)dir-level fail-closed policyreads feature_dir/meta.json

Malformed set (L1, defined once): json.JSONDecodeError; UnicodeDecodeError (explicit raw.decode("utf-8") of bytes before json.loads, because json.loads(bytes) auto-detects encoding and would raise JSONDecodeError, not UnicodeDecodeError); non-dict top level. on_malformed: "raise"MetaDecodeError; "empty"{}; "none"None.

Error-type & message translation per tier (load-bearing)

TierRaises on malformedRationale
L1 decode_metaclass MetaDecodeError(ValueError) (kernel)Extends ValueError so every existing except ValueError boundary keeps catching by inheritance (L3 paths.py:677, decisions/service.py, upgrade/feature_meta.py). NOT KittyInternalConsistencyError (which is not a ValueError → would leak).
L2 _parse_meta_text / parse_meta_fileValueError with legacy path-named message ("Malformed JSON in {path}", "Expected JSON object in {path}, got {type}")Preserves the message-pinned regressions (test_mission_metadata.py:95,101, test_feature_metadata.py:85,92, test_load_meta_fail_closed_authority.py). L2 owns the path-named message; L1 owns the malformed definition.
L3 load_meta_fail_closedMissionMetaReadError (via its existing except ValueError wrap)MetaDecodeError-is-ValueError means the current except ValueError at paths.py:677 still catches.
Site E merge_driver._load_json_objectEventLogMergeError(path) (thin wrapper catches MetaDecodeError)Preserves test_merge_driver_wrappers_2709.py:112-116.

Empty/whitespace-only (NOT L1's concern — C-010): a benign short-circuit at L2 or the caller (→ {} where currently contracted). L1 never sees empty as a special case beyond what json.loads("") would raise; callers that contract empty→benign must short-circuit before calling L1, or use L2's on_malformed mapping deliberately.

VCS-lock comparator (after the mission)

Symbol (target home)SignatureSemantics
kernel.vcs_lock.VCS_LOCK_META_FIELDS (NEW, single named declaration)frozenset[str] = {"vcs", "vcs_locked_at"}the only field-set; no inline-literal duplicates (NFR-002)
kernel.vcs_lock.is_vcs_lock_only_change (NEW)`(before: Mapping \None, after: Mapping) -> bool`

Retired: ref_advance._VCS_LOCK_META_FIELDS, ref_advance._is_vcs_lock_only_meta_change (.get() semantics — absent==null), implement_cores._VCS_LOCK_META_FIELDS, implement_cores._is_vcs_lock_only_meta_diff (sentinel semantics). The kernel comparator adopts the sentinel (absent≠null) semantics as canonical; this changes ref_advance's verdict on the present-but-null arm by design (US2 AC1).

The five read sites (routing target + red-first injection seam)

SiteSymbolInputRoutes ontoCorrupt-arm outcome to preserveInjection seam
Aref_advance._meta_change_is_vcs_lock_onlyworktree file read + git show HEAD:path stdout strkernel L1today: silent None→treats all changed; after: fail loudgit_repo fixture (real git show)
Bref_advance._committed_meta_objectgit show HEAD:path stdout strkernel L1today: silent None; after: fail loudgit_repo fixture
Cimplement_cores._is_self_write_only_diff (decode at implement_cores.py:427)source.read_bytes() byteskernel L1today: Nonereturn False; after: fail loud (the :471 byte-compare is NOT a decode — untouched; keep the read inline for test_trio_seam_only)on-disk file (no git_repo — unit-testable)
Dimplement_cores._committed_meta_mappingGitPort.show_blob byteskernel L1today: silent None; after: fail loudinjectable GitPort fake (no real git / git_repo marker)
Emerge_driver._load_json_object:174on-disk temp blob Pathpublic L2 (parse_meta_file) via a wrapperpreserve empty→{}; two error arms — malformed→currently-unnamed JSONDecodeError, non-object→already-named EventLogMergeError; wrapper catches MetaDecodeErrorEventLogMergeError(path)on-disk file

Not a site: merge_driver._parse_json_document:337 decodes the issue/row-matrix document (raises RowMatrixMergeError), not meta.json — out of scope; the FR-010 gate must exclude it. Only sites A/B (ref_advance shells out to real git show) carry [integration, git_repo]; C/D/E are unit-testable without real git.

Governance gates (invariants after the mission)

Gate (test symbol)Invariant
test_inline_meta_read_floorinline json.loads over meta paths ≤ floor 7 (unchanged; L1 in kernel is excluded)
test_routed_load_meta_floorlive - MARGIN(4) <= ROUTED_LOAD_META_FLOOR < live, anti-vacuity live > floor; ROUTED_CALLEES extended with decode_meta + parse_meta_file once, in IC-05; floor re-pinned to fresh_live - 3. Routing ICs (02/03/04) are census-neutral — they route onto these still-uncounted names, so the gate stays green at 130/134 until IC-05.
test_no_unaccounted_load_meta_call_sitesload_meta call-site ledger stays exact
FR-010 enumeration gate (NEW)0 json.loads/json.load over meta content outside kernel L1 — scoped by an argument/path allow-set that excludes _parse_json_document:337 (row-matrix) and the kernel L1 itself
FR-010 completeness gate (NEW)0 un-routed meta.json bypass reads beyond the enumerated set
NFR-004 ratchet (test_layer_rules)git/ref_advance.py imports 0 specify_cli modules
allow-list controls (existing)test_allowlist_matches_floor (==) + test_allowlist_shrink_only; #3240 recorded as deviation

State: corrupt-vs-benign decision (per read)

read bytes/text/path
  ├─ missing file ........→ caller benign contract (e.g. {} or None)   [preserved]
  ├─ empty/whitespace ....→ caller/L2 benign short-circuit (→ {})      [preserved, C-010]
  ├─ valid dict ..........→ dict                                        [behavior-preserving]
  └─ malformed ...........→ MetaDecodeError (fail loud, names meta.json + source id)  [NEW behavior — was silent]