From c78322bf8edef87f228948c6adbc1ad6b544a960 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 05:17:56 +0000 Subject: [PATCH 01/25] Give the SCM object store two kinds of object, so a file has somewhere to live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY: `gunbc scm add ` could not be built, and the blocker was the model rather than the wiring. `ObjectStore` held `List` -- a NodeKind plus labelled children -- so a file's bytes had exactly one route in: intern them as an Atom's Symbol. That makes the symbol table a content store and gives one name two materially different meanings, which is DESIGN ยง3's meaning fork, and it is the same wrong model I already rejected for the srv2 mirror. WHAT: `ScmObject = SemanticNodeObject(ObjectRecord) | AuthoredSourceObject(AuthoredSourceRecord)`, under ONE content identity family, because a store holding two identity kinds would make `store_contains` ambiguous about what it contains. The authored identity is derived from the bytes by `content_hash_atom` under a family tag, so a caller supplies content and never an identity -- the same wall `store_node` and `insert_from_structure` already stand behind, reached by a third producer rather than a second identity authority. THE EMPTY FILE HAS ITS OWN CONSTRUCTOR. `content_hash_atom` takes a NonEmptyStr, and hashing "" through some other path would give the empty file an identity minted by a different rule than every other file. `AuthoredSourceContent = AuthoredSourceEmpty | AuthoredSourceText(NonEmptyStr)` makes the degenerate case unwritable rather than admitted and checked. REFUSALS ADDED, EACH BECAUSE A CONSUMER CAN NOW REACH A KIND IT CANNOT ACT ON. Every site that needs a node asks `find_node_record`, whose three arms separate "nothing is here" from "a file is here where a program was required" -- folding those into one absent arm would send an operator looking for bytes that are right there. - checkout: `ObjectIsAuthoredSource` - merge: `TargetRootIsAuthoredSource` - insert: `objects_equal` answers false ACROSS arms, so a file landing on a node's locator is a LocatorCollision rather than silently satisfying a checkout. THE CODEC IS A STRICT EXTENSION, NOT A FORMAT VERSION. A node entry keeps `{kind, children}` byte for byte, so every document the predecessor accepted still decodes identically; an authored entry is `{source}`. The two member sets are disjoint, which is what makes discriminating on shape a partition rather than a guess -- and an entry matching neither still refuses by member set. The decoder routes bytes through `store_authored_source`, so an image cannot assert an identity for a file any more than it can for a node. EXECUTED, five new claims enrolled in test.claim.scm.scm_object_store_collision_witness (bodies in the defining module, where sole_constructor lets the impostor fixture exist): identity follows the bytes AND differs for different bytes; storing one file twice stores it once; the empty file is an object and is not the same object as " "; a file under a node's locator refuses; and the control that the same file stores cleanly under its own derived identity. All five evaluate `true`, as does the migrated both_insertion_orders_produce_the_same_canonical_record and the checkout witness's lost-object arm. Not in this commit: the `add` and `commit` verbs. They now have somewhere to write, and they need the one modeled ScmWriteOutcome the plan names -- that is the next increment, not a wiring afterthought. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT --- dag/gunbc/scm/checkout.dag | 21 +- dag/gunbc/scm/commit_closure.dag | 16 +- dag/gunbc/scm/commit_closure_json_v2.dag | 115 +++++++- dag/gunbc/scm/merge.dag | 13 +- dag/gunbc/scm/object_store.dag | 264 ++++++++++++++++-- .../scm/scm_checkout_verb_witness_test.dag | 2 + dag/test/claim/scm/scm_merge_witness_test.dag | 4 + ...cm_object_store_collision_witness_test.dag | 33 +++ .../scm/scm_object_store_witness_test.dag | 21 +- 9 files changed, 443 insertions(+), 46 deletions(-) diff --git a/dag/gunbc/scm/checkout.dag b/dag/gunbc/scm/checkout.dag index 0c09f3ac82b..59428542599 100644 --- a/dag/gunbc/scm/checkout.dag +++ b/dag/gunbc/scm/checkout.dag @@ -16,7 +16,10 @@ import gunbc.scm.object_store { ObjectRecord, ObjectStore, StoredEdge, - find_object, + NodeRecordFound, + NodeRecordAbsent, + NodeRecordIsAuthoredSource, + find_node_record, } // A COMMIT IS A ROOT IDENTITY AND NOTHING ELSE. No parent, no author, no timestamp, no message, no @@ -28,8 +31,16 @@ type Commit { root: ObjectId } +// AN AUTHORED SOURCE AT A PROGRAM POSITION IS ITS OWN REFUSAL, NOT A MISSING OBJECT. +// +// Once the store holds two kinds of object, a checkout can reach an identity that IS present and is +// a file rather than a program. Reporting that as MissingObject would send an operator looking for +// bytes that are right there, which is the not-applicable-rendered-as-malformed failure -- the two +// have opposite remedies. It is also not silently skippable: reconstructing the node without that +// child would hand back a program the commit does not name. type CheckoutRefusal = MissingObject { identity: ObjectId } + | ObjectIsAuthoredSource { identity: ObjectId } type CheckoutOutcome = CheckedOut { program: Node } @@ -87,9 +98,11 @@ fn checkout_record(store: ObjectStore, record: ObjectRecord) -> CheckoutOutcome } fn checkout_object(store: ObjectStore, identity: ObjectId) -> CheckoutOutcome { - match find_object(store: store, identity: identity) { - Absent => CheckoutRefused { cause: MissingObject { identity: identity } } - Present { value: record } => checkout_record(store: store, record: record) + match find_node_record(store: store, identity: identity) { + NodeRecordAbsent { identity: i } => CheckoutRefused { cause: MissingObject { identity: i } } + NodeRecordIsAuthoredSource { identity: i } => + CheckoutRefused { cause: ObjectIsAuthoredSource { identity: i } } + NodeRecordFound(record) => checkout_record(store: store, record: record) } } diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index bab22bd9fc3..6daa43106f5 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -81,6 +81,9 @@ import std.types { Bool, Int, List, list_length } import gunbc.scm.object_store { ObjectId, ObjectRecord, + ScmObject, + SemanticNodeObject, + AuthoredSourceObject, ObjectStore, StoredEdge, object_id_eq, @@ -269,11 +272,22 @@ fn uncontained_targets(store: ObjectStore) -> List { fold( store_records_in_dependency_order(store: store), init: [], - f: fn(acc, record) { uncontained_in_record(store: store, acc: acc, record: record) } + f: fn(acc, object) { uncontained_in_object(store: store, acc: acc, object: object) } ) ) } +// AN AUTHORED SOURCE HAS NO OUTBOUND REFERENCES, so it contributes nothing to the unresolved set -- +// and that is a fact about its shape rather than a skip. Writing it as a match makes the reason +// visible at the site: a later object kind that DOES carry references cannot be added without this +// fold refusing to compile, whereas a filter on "is it a node" would silently ignore it. +fn uncontained_in_object(store: ObjectStore, acc: List, object: ScmObject) -> List { + match object { + AuthoredSourceObject(_) => acc + SemanticNodeObject(record) => uncontained_in_record(store: store, acc: acc, record: record) + } +} + fn uncontained_in_record(store: ObjectStore, acc: List, record: ObjectRecord) -> List { fold( record.children, diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 1156cef643a..60bb52c8a54 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -107,13 +107,21 @@ import gunbc.scm.object_store { ObjectId, ObjectRecord, ObjectStore, + ScmObject, + SemanticNodeObject, + AuthoredSourceObject, + AuthoredSourceContent, + AuthoredSourceEmpty, + AuthoredSourceText, StoredEdge, StoreOutcome, Stored, LocatorCollision, empty_store, insert_from_structure, + store_authored_source, object_id_eq, + scm_object_identity, store_records_in_dependency_order, } @@ -200,6 +208,7 @@ type ClosureDocEncodeRefusal type ClosureDocMemberContext = ClosureDocEnvelopeMembers | ClosureDocObjectRecordMembers + | ClosureDocAuthoredSourceMembers | ClosureDocTypeKindMembers | ClosureDocAtomKindMembers | ClosureDocComputationKindMembers @@ -402,6 +411,37 @@ fn encode_record(positions: EncodePositions, record: ObjectRecord) -> JsonValue ) } +// THE TWO ENTRY SHAPES ARE DISJOINT MEMBER SETS, AND THAT IS THE DISCRIMINATOR. +// +// A node entry keeps `{kind, children}` unchanged, byte for byte, so every document the predecessor +// accepted this codec still accepts and decodes identically -- this is a strict extension, not a +// format version. An authored source entry is `{source}`. Nothing matches both, and an entry +// matching NEITHER refuses by member set rather than being read as the closest fit: shape-sniffing +// is admissible only when the shapes partition, and the refusal is what keeps it a partition rather +// than a guess. +// +// THE CONTENT IS ONE JSON STRING AND THE EMPTY FILE IS THE EMPTY ONE. AuthoredSourceContent's arms +// partition exactly on emptiness, so the encoding is total and the decode reconstructs the same arm +// through authored_source_content -- no tag beside the payload that could disagree with it. +fn encode_authored_source_content(content: AuthoredSourceContent) -> JsonValue { + match content { + AuthoredSourceEmpty => json_string(s: "") + AuthoredSourceText(text) => json_string(s: text as String) + } +} + +fn encode_object(positions: EncodePositions, object: ScmObject) -> JsonValue { + match object { + SemanticNodeObject(record) => encode_record(positions: positions, record: record) + AuthoredSourceObject(record) => + json_object( + members: [ + json_kv(key: "source", value: encode_authored_source_content(content: record.content)), + ] + ) + } +} + type EncodeAcc { positions: EncodePositions objects: List @@ -411,17 +451,17 @@ type EncodeAcc { // std.selected_witness_bundle all reverse a prepending fold). concat-at-end copies the whole // accumulator on every step; prepending does not, so the emitted order is restored by one linear // reverse instead of being bought with a quadratic. -fn encode_step(acc: EncodeAcc, record: ObjectRecord) -> EncodeAcc { +fn encode_step(acc: EncodeAcc, object: ScmObject) -> EncodeAcc { EncodeAcc { positions: EncodePositions { by_identity: map_insert( m: acc.positions.by_identity, - key: object_id_key(identity: record.identity), + key: object_id_key(identity: scm_object_identity(object: object)), value: position_reference(n: acc.positions.next), ), next: acc.positions.next + 1, }, - objects: concat([encode_record(positions: acc.positions, record: record)], acc.objects), + objects: concat([encode_object(positions: acc.positions, object: object)], acc.objects), } } @@ -437,7 +477,7 @@ fn encode_object_table(store: ObjectStore) -> EncodeAcc { match fold( store_records_in_dependency_order(store: store), init: EncodeAcc { positions: EncodePositions { by_identity: empty_map(), next: 0 }, objects: [] }, - f: fn(acc, record) { encode_step(acc: acc, record: record) } + f: fn(acc, object) { encode_step(acc: acc, object: object) } ) { EncodeAcc { positions: positions, objects: reversed } => EncodeAcc { positions: positions, objects: reverse(reversed) } @@ -806,17 +846,70 @@ fn decode_acc_refused(acc: DecodeAcc, cause: ClosureDocRefusal) -> DecodeAcc { } } +// CLASSIFY BEFORE READING, AND REFUSE WHEN NEITHER SHAPE FITS. +// +// The two entry shapes have disjoint member sets, so which one an entry is is decided by whether it +// carries `source`. That question is asked FIRST, because asking it after attempting the node read +// would report a node-shaped complaint about a file-shaped entry -- the not-applicable-rendered-as- +// malformed failure. An entry carrying neither is refused by the node member set, which is the +// pre-existing behaviour for an unrecognized entry and stays the default: a new shape has to be +// added here deliberately, it cannot arrive by resembling something. fn decode_object_step(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { match acc.refusal { Present { value: _ } => acc Absent => - match member_set_refusal( - v: object_value, - allowed: ["kind", "children"], - context: ClosureDocObjectRecordMembers - ) { - Present { value: c } => decode_acc_refused(acc: acc, cause: c) - Absent => decode_object_members(acc: acc, object_value: object_value) + match read_member(v: object_value, key: "source") { + MemberString { value: text } => + match member_set_refusal( + v: object_value, + allowed: ["source"], + context: ClosureDocAuthoredSourceMembers + ) { + Present { value: c } => decode_acc_refused(acc: acc, cause: c) + Absent => decode_authored_source(acc: acc, text: text) + } + MemberArray { elements: _ } => + decode_acc_refused( + acc: acc, + cause: ClosureDocMemberReadRefused { cause: MemberReadWrongShape { key: "source" } } + ) + MemberObject { value: _ } => + decode_acc_refused( + acc: acc, + cause: ClosureDocMemberReadRefused { cause: MemberReadWrongShape { key: "source" } } + ) + MemberFailed { cause: _ } => + match member_set_refusal( + v: object_value, + allowed: ["kind", "children"], + context: ClosureDocObjectRecordMembers + ) { + Present { value: c } => decode_acc_refused(acc: acc, cause: c) + Absent => decode_object_members(acc: acc, object_value: object_value) + } + } + } +} + +// THE DECODER IS NOT A THIRD PRODUCER OF AUTHORED IDENTITIES. It hands the bytes to +// store_authored_source, which derives the identity by the one rule -- so an image cannot assert an +// identity for a file any more than it can for a node, and the position map records what the store +// derived rather than what the document said. +fn decode_authored_source(acc: DecodeAcc, text: String) -> DecodeAcc { + match store_authored_source(store: acc.store, text: text) { + LocatorCollision { identity: i, existing: _, incoming: _ } => + DecodeAcc { + store: acc.store, + positions: acc.positions, + next: acc.next, + refusal: Present { value: ClosureDocObjectTableRefused { cause: ObjectTableObjectCollision { identity: i } } }, + } + Stored { store: next_store, identity: identity } => + DecodeAcc { + store: next_store, + positions: map_insert(m: acc.positions, key: position_reference(n: acc.next), value: identity), + next: acc.next + 1, + refusal: acc.refusal, } } } diff --git a/dag/gunbc/scm/merge.dag b/dag/gunbc/scm/merge.dag index 7f5d61542e7..f307b147bd5 100644 --- a/dag/gunbc/scm/merge.dag +++ b/dag/gunbc/scm/merge.dag @@ -26,6 +26,10 @@ import gunbc.scm.object_store { Stored, LocatorCollision, ObjectRecord, + NodeRecordFound, + NodeRecordAbsent, + NodeRecordIsAuthoredSource, + find_node_record, StoredEdge, object_id_eq, store_node, @@ -201,6 +205,7 @@ type ResolvedRole { // placement violation at the shared resource, neither of which is an endpoint. type MergeRefusal = TargetRootMissing { identity: ObjectId } + | TargetRootIsAuthoredSource { identity: ObjectId } | TargetChildMissing { role: Symbol, identity: ObjectId } | BindingLocatorCollision { role: Symbol, identity: ObjectId } | RequiredDependencyRemoved { dependency: MergeDependency, removed_role: Symbol } @@ -296,9 +301,11 @@ fn edge_label_role(label: EdgeLabel) -> Symbol? { // preservation law mean object reuse: nothing is recomputed for a role no proposal implicates, so // "unchanged" is the same object and not a fresh derivation that hashes equal. fn target_roles(store: ObjectStore, target: Commit) -> TargetReadOutcome { - match find_object(store: store, identity: target.root) { - Absent => TargetUnreadable { cause: TargetRootMissing { identity: target.root } } - Present { value: record } => + match find_node_record(store: store, identity: target.root) { + NodeRecordAbsent { identity: i } => TargetUnreadable { cause: TargetRootMissing { identity: i } } + NodeRecordIsAuthoredSource { identity: i } => + TargetUnreadable { cause: TargetRootIsAuthoredSource { identity: i } } + NodeRecordFound(record) => TargetRead { roles: reverse(fold(record.children, init: [], f: fn(acc, stored) { match edge_label_role(label: stored.label) { diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index ef673909e7a..e123c5a8d33 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -1,6 +1,7 @@ module gunbc.scm.object_store -import std.types { List, Bool, Int, String } +import std.types { List, Bool, Int, String, NonEmptyStr } +import std.content_hash { content_hash_atom, content_hash_tagged_structural } import v2.std.node { Hash, Node, @@ -62,6 +63,78 @@ type ObjectRecord sole_constructor { children: List } +// AUTHORED SOURCE IS A SECOND KIND OF OBJECT, NOT A NODE WEARING A COSTUME. +// +// A file's bytes are not a semantic node, and the only way to push them through the node carrier is +// to intern them as an Atom's Symbol -- which makes the symbol table a content store and gives one +// name two materially different meanings. That is the meaning fork DESIGN section 3 names, and it +// was the wrong model for the srv2 mirror for exactly this reason. So authored source gets its own +// arm, under ONE content identity family shared with semantic objects, because a store that held +// two identity kinds would make `store_contains` ambiguous about what it contains. +// +// THE EMPTY FILE HAS ITS OWN CONSTRUCTOR RATHER THAN AN EMPTY STRING. content_hash_atom takes a +// NonEmptyStr, so an empty file has no digest through that route -- and the tempting repair, hashing +// "" through some other path, would give the empty file an identity minted by a different rule than +// every other file. An arm makes the distinction at authoring time instead of validating it later, +// which is the carrier choice DESIGN section 5 asks for: the degenerate case is unwritable as a +// degenerate NonEmptyStr rather than admitted and checked. +type AuthoredSourceContent + = AuthoredSourceEmpty + | AuthoredSourceText(NonEmptyStr) + +// THE TAGS ARE THE FAMILY SEPARATION, AND THEY ARE NOT DECORATION. Without them an authored source +// whose text digest happened to equal a node's structural digest would be indistinguishable at the +// locator, and the store would answer `contains` for the wrong kind of thing. Tagging does not make +// a collision impossible -- nothing at the declared fnv1a64 rung does -- it makes the two families +// disjoint by construction under the hash rule, so a collision between them is the same accident as +// a collision within one, and insert_derived refuses it identically. +data authored_source_text_tag: NonEmptyStr = "gunbc.scm.authored_source.text" +data authored_source_empty_tag: NonEmptyStr = "gunbc.scm.authored_source.empty" + +// THE GUARD IS AN EQUALITY, NOT A CAST. `text as NonEmptyStr` on a computed value refuses at +// runtime rather than answering, so it cannot be the discriminator for a total function whose whole +// job is to decide which arm an arbitrary file's bytes inhabit. The cast is applied only inside the +// arm the guard has already established, where it cannot refuse. +fn authored_source_content(text: String) -> AuthoredSourceContent { + if text == "" { + AuthoredSourceEmpty + } else { + AuthoredSourceText(text as NonEmptyStr) + } +} + +fn authored_source_identity(content: AuthoredSourceContent) -> ObjectId { + match content { + AuthoredSourceEmpty => content_hash_atom(value: authored_source_empty_tag) + AuthoredSourceText(text) => + content_hash_tagged_structural( + tag: authored_source_text_tag, + payload: content_hash_atom(value: text), + ) + } +} + +type AuthoredSourceRecord sole_constructor { + identity: ObjectId + content: AuthoredSourceContent +} + +// ONE STORED THING, TWO KINDS. Every consumer that reaches into a stored object now has to say which +// kind it can act on -- checkout reconstructs a node and cannot reconstruct a file, a closure walk +// follows edges an authored source does not have -- and saying so is the point. A shared supertype +// with an optional children list would have let each of those consumers read an empty list and +// proceed, which is the empty-observation narrow. +type ScmObject + = SemanticNodeObject(ObjectRecord) + | AuthoredSourceObject(AuthoredSourceRecord) + +fn scm_object_identity(object: ScmObject) -> ObjectId { + match object { + SemanticNodeObject(record) => record.identity + AuthoredSourceObject(record) => record.identity + } +} + // The store is a LIST rather than a map, deliberately and temporarily. std.determinism refuses the // uniquely named Map projections whose traversal order is host-unspecified, and a store whose // iteration order leaked into a digest would make one program hash two ways. A list keeps ordering @@ -78,7 +151,7 @@ type ObjectRecord sole_constructor { // dissolve-on: a determinism-admitted keyed collection; find_object and insert_derived are then the // only sites that change. type ObjectStore sole_constructor { - objects: List + objects: List } // How many objects a store holds, without naming how it holds them. @@ -117,7 +190,7 @@ fn store_object_count(store: ObjectStore) -> Int { // appends the parent, so a record is appended only after every record it references. Persistence // depends on that: an image whose edges point at EARLIER positions can be decoded in one forward // pass with no forward references to resolve and no identities to trust. -fn store_records_in_dependency_order(store: ObjectStore) -> List { +fn store_records_in_dependency_order(store: ObjectStore) -> List { store.objects } @@ -129,16 +202,39 @@ fn object_id_eq(left: ObjectId, right: ObjectId) -> Bool { (left.digest as String) == (right.digest as String) } -fn find_object(store: ObjectStore, identity: ObjectId) -> ObjectRecord? { - fold(store.objects, init: none, f: fn(acc, record) { - if object_id_eq(left: record.identity, right: identity) { - Present { value: record } +fn find_object(store: ObjectStore, identity: ObjectId) -> ScmObject? { + fold(store.objects, init: none, f: fn(acc, object) { + if object_id_eq(left: scm_object_identity(object: object), right: identity) { + Present { value: object } } else { acc } }) } +// A CONSUMER THAT NEEDS A NODE ASKS FOR ONE, AND IS TOLD WHEN WHAT IS THERE IS NOT A NODE. +// +// The three arms are not two: an identity nothing holds and an identity holding a FILE are +// different facts with different remedies -- "you named something that is not here" versus "you +// named a file where a program was required" -- and folding them into one absent arm is the +// state-space conflation this module keeps refusing. A caller that only needs presence still calls +// store_contains, which is deliberately kind-agnostic: it answers about the locator. +type NodeRecordLookup + = NodeRecordFound(ObjectRecord) + | NodeRecordAbsent { identity: ObjectId } + | NodeRecordIsAuthoredSource { identity: ObjectId } + +fn find_node_record(store: ObjectStore, identity: ObjectId) -> NodeRecordLookup { + match find_object(store: store, identity: identity) { + Absent => NodeRecordAbsent { identity: identity } + Present { value: object } => + match object { + SemanticNodeObject(record) => NodeRecordFound(record) + AuthoredSourceObject(_) => NodeRecordIsAuthoredSource { identity: identity } + } + } +} + fn store_contains(store: ObjectStore, identity: ObjectId) -> Bool { match find_object(store: store, identity: identity) { Absent => false @@ -185,7 +281,7 @@ fn store_contains(store: ObjectStore, identity: ObjectId) -> Bool { // question is whether they are the same one. type StoreOutcome = Stored { store: ObjectStore, identity: ObjectId } - | LocatorCollision { identity: ObjectId, existing: ObjectRecord, incoming: ObjectRecord } + | LocatorCollision { identity: ObjectId, existing: ScmObject, incoming: ScmObject } // Record equality is the substrate's own structural equality over both fields. StoredEdge is a // label and a derived child identity, so comparing the child LISTS compares the whole subtree by @@ -194,22 +290,61 @@ fn records_equal(left: ObjectRecord, right: ObjectRecord) -> Bool { (left.kind == right.kind) && (left.children == right.children) } -fn insert_derived(store: ObjectStore, record: ObjectRecord) -> StoreOutcome { - match find_object(store: store, identity: record.identity) { +// CROSS-KIND IS A COLLISION, NOT AN EQUALITY. A node and a file under one locator are two different +// canonical objects, so the answer here is false and insert_derived refuses -- the same treatment as +// two different nodes. Answering true would let a file silently satisfy a checkout that wanted a +// program, which is the fabricated plausible output this module's collision arm exists to remove. +fn objects_equal(left: ScmObject, right: ScmObject) -> Bool { + match left { + SemanticNodeObject(l) => + match right { + SemanticNodeObject(r) => records_equal(left: l, right: r) + AuthoredSourceObject(_) => false + } + AuthoredSourceObject(l) => + match right { + SemanticNodeObject(_) => false + AuthoredSourceObject(r) => l.content == r.content + } + } +} + +fn insert_object(store: ObjectStore, object: ScmObject) -> StoreOutcome { + let identity = scm_object_identity(object: object) + match find_object(store: store, identity: identity) { Absent => Stored { - store: ObjectStore { objects: concat(store.objects, [record]) }, - identity: record.identity, + store: ObjectStore { objects: concat(store.objects, [object]) }, + identity: identity, } Present { value: existing } => - if records_equal(left: existing, right: record) { - Stored { store: store, identity: record.identity } + if objects_equal(left: existing, right: object) { + Stored { store: store, identity: identity } } else { - LocatorCollision { identity: record.identity, existing: existing, incoming: record } + LocatorCollision { identity: identity, existing: existing, incoming: object } } } } +fn insert_derived(store: ObjectStore, record: ObjectRecord) -> StoreOutcome { + insert_object(store: store, object: SemanticNodeObject(record)) +} + +// THE AUTHORED-SOURCE PRODUCER, AND IT DERIVES ITS IDENTITY EXACTLY AS THE OTHER TWO DO. A caller +// supplies bytes and nothing else; the identity follows from those bytes by the one rule above, so +// there is no argument through which a caller could assert an identity it did not derive. This is +// the same wall store_node and insert_from_structure stand behind, reached by a third producer +// rather than by a second identity authority. +fn store_authored_source(store: ObjectStore, text: String) -> StoreOutcome { + let content = authored_source_content(text: text) + insert_object( + store: store, + object: AuthoredSourceObject( + AuthoredSourceRecord { identity: authored_source_identity(content: content), content: content }, + ), + ) +} + // Grafting an entry between stores cannot forge, because it can only move a pairing some earlier // store_node already derived. This is what lets a caller build a deliberately INCOMPLETE closure -- // a root without its children -- without ever being able to build a DISHONEST one. @@ -227,13 +362,13 @@ fn insert_derived(store: ObjectStore, record: ObjectRecord) -> StoreOutcome { type CopyOutcome = Copied { store: ObjectStore, identity: ObjectId } | CopySourceAbsent { identity: ObjectId } - | CopyLocatorCollision { identity: ObjectId, existing: ObjectRecord, incoming: ObjectRecord } + | CopyLocatorCollision { identity: ObjectId, existing: ScmObject, incoming: ScmObject } fn copy_object(from: ObjectStore, to: ObjectStore, identity: ObjectId) -> CopyOutcome { match find_object(store: from, identity: identity) { Absent => CopySourceAbsent { identity: identity } - Present { value: record } => - match insert_derived(store: to, record: record) { + Present { value: object } => + match insert_object(store: to, object: object) { Stored { store: store, identity: i } => Copied { store: store, identity: i } LocatorCollision { identity: i, existing: e, incoming: c } => CopyLocatorCollision { identity: i, existing: e, incoming: c } @@ -449,7 +584,7 @@ fn a_different_record_under_one_locator_refuses() -> Bool { Stored { store: _, identity: _ } => false LocatorCollision { identity: reported, existing: existing, incoming: incoming } => object_id_eq(left: reported, right: identity) - && !records_equal(left: existing, right: incoming) + && !objects_equal(left: existing, right: incoming) } } @@ -532,6 +667,95 @@ fn a_copy_into_a_colliding_destination_refuses() -> Bool { CopySourceAbsent { identity: _ } => false CopyLocatorCollision { identity: reported, existing: existing, incoming: incoming } => object_id_eq(left: reported, right: identity) - && !records_equal(left: existing, right: incoming) + && !objects_equal(left: existing, right: incoming) + } +} + +// ------------------------------------------------------------------------------------------------ +// AUTHORED SOURCE, EXECUTED. Same split as the collision bodies above and for the same reason: the +// impostor fixtures need this module's seal, and floor discovery only finds `test fn` in a path +// ending `_test.dag`, so the bodies live here and the enrolled wrappers live in +// dag/test/claim/scm/scm_object_store_collision_witness_test.dag. +// ------------------------------------------------------------------------------------------------ + +// THE SAME BYTES ARE THE SAME OBJECT, AND DIFFERENT BYTES ARE NOT. Without the second half this +// would pass against a producer that returned one fixed identity for every file. +fn authored_source_identity_follows_its_bytes() -> Bool { + let one = store_authored_source(store: empty_store(), text: "hello\n") + let two = store_authored_source(store: empty_store(), text: "hello\n") + let other = store_authored_source(store: empty_store(), text: "goodbye\n") + match one { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, identity: a } => + match two { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, identity: b } => + match other { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, identity: c } => + object_id_eq(left: a, right: b) && !object_id_eq(left: a, right: c) + } + } + } +} + +// STORING THE SAME FILE TWICE IS IDEMPOTENT, exactly as it is for a node -- content is fungible. +fn the_same_authored_source_stores_once() -> Bool { + match store_authored_source(store: empty_store(), text: "hello\n") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: first, identity: _ } => + match store_authored_source(store: first, text: "hello\n") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: again, identity: _ } => + (store_object_count(store: first) == 1) && (store_object_count(store: again) == 1) + } + } +} + +// THE EMPTY FILE IS AN OBJECT, NOT AN ABSENCE, and it is not the same object as any nonempty one. +// The tempting implementation -- treat empty content as nothing stored -- is the empty-observation +// narrow, and it would make `add` on an empty file silently do nothing. +fn the_empty_file_is_its_own_object() -> Bool { + match store_authored_source(store: empty_store(), text: "") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: store, identity: empty_identity } => + match store_authored_source(store: empty_store(), text: " ") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, identity: blank_identity } => + (store_object_count(store: store) == 1) + && !object_id_eq(left: empty_identity, right: blank_identity) + } + } +} + +// A FILE UNDER A NODE'S LOCATOR REFUSES, WHICH IS THE ARM THE TWO-KIND STORE ADDS. objects_equal +// answers false across arms, so insert_object refuses rather than treating a present locator as +// identity of content -- and a checkout that wanted the node would otherwise have been handed a +// file. Built from an impostor because a real cross-kind fnv1a64 collision needs a preimage search: +// what is under test is the arm, not the digest. +fn a_file_under_a_nodes_locator_refuses() -> Bool { + let real = store_node(store: empty_store(), n: collision_probe_atom(identity: ^collide_kind)) + let identity = collision_probe_identity( + outcome: real, + fallback: content_hash(n: collision_probe_atom(identity: ^collide_kind)), + ) + let content = authored_source_content(text: "not a program\n") + match insert_object( + store: collision_probe_stored(outcome: real), + object: AuthoredSourceObject(AuthoredSourceRecord { identity: identity, content: content }), + ) { + Stored { store: _, identity: _ } => false + LocatorCollision { identity: reported, existing: existing, incoming: incoming } => + object_id_eq(left: reported, right: identity) + && !objects_equal(left: existing, right: incoming) + } +} + +// CONTROL for the arm above: the same file stores cleanly under its OWN derived identity, so the +// refusal is caused by the locator clash and not by authored source being unstorable. +fn the_same_file_stores_cleanly_under_its_own_identity() -> Bool { + match store_authored_source(store: empty_store(), text: "not a program\n") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: store, identity: _ } => store_object_count(store: store) == 1 } } diff --git a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag index 2cc83e7fec7..4b3b6237de4 100644 --- a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag +++ b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag @@ -56,6 +56,7 @@ import gunbc.scm.checkout { Commit, CheckoutRefusal, MissingObject, + ObjectIsAuthoredSource, RepositoryCheckout, RepositoryCheckedOut, CheckoutReferenceUnresolved, @@ -193,6 +194,7 @@ test fn scm_ck_a_lost_object_is_the_stores_fact_not_the_callers() -> Bool { r == scm_ck_ref(ordinal: 0) && match cause { MissingObject { identity: _ } => true + ObjectIsAuthoredSource { identity: _ } => false } CheckoutReferenceUnresolved { reference: _ } => false RepositoryCheckedOut { repository: _, program: _ } => false diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 3c579ef97cb..5bff60ed8cc 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -42,6 +42,7 @@ import gunbc.scm.merge { DesiredBindingAbsent, MergeRefused, MergeRefusal, + TargetRootIsAuthoredSource, RequiredDependencyRemoved, DependencyKindUnsupportedForMerge, ResolvedRole, @@ -158,6 +159,7 @@ fn refusal_locates( && ((dep.usage_site as String) == (usage_site as String)) && ((removed as String) == (required as String)) TargetRootMissing { identity: _ } => false + TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false DependencyKindUnsupportedForMerge { dependent: _, required: _, kind: _ } => false @@ -281,6 +283,7 @@ test fn the_arm_a_dependency_row_is_actually_read() -> Bool { (kind as String) == (^barrier_before as String) RequiredDependencyRemoved { dependency: _, removed_role: _ } => false TargetRootMissing { identity: _ } => false + TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false } @@ -478,6 +481,7 @@ test fn an_unsupported_dependency_kind_refuses() -> Bool { (kind as String) == (^placement_depends_on as String) RequiredDependencyRemoved { dependency: _, removed_role: _ } => false TargetRootMissing { identity: _ } => false + TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false } diff --git a/dag/test/claim/scm/scm_object_store_collision_witness_test.dag b/dag/test/claim/scm/scm_object_store_collision_witness_test.dag index a536f0c0dc1..1f6da56f7b9 100644 --- a/dag/test/claim/scm/scm_object_store_collision_witness_test.dag +++ b/dag/test/claim/scm/scm_object_store_collision_witness_test.dag @@ -19,6 +19,11 @@ import gunbc.scm.object_store { a_child_collision_propagates_and_the_parent_is_not_stored, the_same_parent_stores_cleanly_without_the_impostor, a_copy_into_a_colliding_destination_refuses, + authored_source_identity_follows_its_bytes, + the_same_authored_source_stores_once, + the_empty_file_is_its_own_object, + a_file_under_a_nodes_locator_refuses, + the_same_file_stores_cleanly_under_its_own_identity, } // One locator, two different canonical records -- refused, and the refusal names both records so a @@ -51,3 +56,31 @@ test fn the_parent_fixture_stores_cleanly_on_its_own() -> Bool { test fn copying_into_a_colliding_destination_refuses() -> Bool { a_copy_into_a_colliding_destination_refuses() } + +// ------------------------------------------------------------------------------------------------ +// AUTHORED SOURCE. The store now holds two kinds of object, and these are the arms that distinction +// adds. The bodies are in gunbc.scm.object_store, where the seal lets the impostor fixture exist. +// ------------------------------------------------------------------------------------------------ + +test fn an_authored_sources_identity_follows_its_bytes() -> Bool { + authored_source_identity_follows_its_bytes() +} + +test fn storing_one_file_twice_stores_it_once() -> Bool { + the_same_authored_source_stores_once() +} + +test fn an_empty_file_is_an_object_rather_than_an_absence() -> Bool { + the_empty_file_is_its_own_object() +} + +// The cross-kind collision arm: a file landing on a node's locator refuses instead of shadowing it. +test fn a_file_under_a_nodes_locator_is_refused() -> Bool { + a_file_under_a_nodes_locator_refuses() +} + +// CONTROL, so the refusal above is attributable to the locator clash rather than to authored source +// being unstorable at all. +test fn the_file_fixture_stores_cleanly_on_its_own() -> Bool { + the_same_file_stores_cleanly_under_its_own_identity() +} diff --git a/dag/test/claim/scm/scm_object_store_witness_test.dag b/dag/test/claim/scm/scm_object_store_witness_test.dag index 99d9f95d485..6f6795610d8 100644 --- a/dag/test/claim/scm/scm_object_store_witness_test.dag +++ b/dag/test/claim/scm/scm_object_store_witness_test.dag @@ -47,7 +47,10 @@ import gunbc.scm.object_store { ObjectRecord, StoredEdge, ObjectStore, - find_object, + NodeRecordFound, + NodeRecordAbsent, + NodeRecordIsAuthoredSource, + find_node_record, store_contains, store_object_count, copy_object, @@ -63,6 +66,7 @@ import gunbc.scm.checkout { Commit, CheckoutRefusal, MissingObject, + ObjectIsAuthoredSource, commit_of_root, checkout, CheckedOut, @@ -182,6 +186,7 @@ fn refusal_names(store: ObjectStore, commit: Commit, expected: ObjectId) -> Bool CheckoutRefused { cause: cause } => match cause { MissingObject { identity: identity } => object_id_eq(left: identity, right: expected) + ObjectIsAuthoredSource { identity: _ } => false } } } @@ -335,12 +340,14 @@ test fn reordered_named_children_share_one_identity() -> Bool { // of one was already true under the defect, because dedup was keyed on identity and the identity // was never in question. fn same_record(left: ObjectStore, right: ObjectStore, identity: ObjectId) -> Bool { - match find_object(store: left, identity: identity) { - Absent => false - Present { value: l } => - match find_object(store: right, identity: identity) { - Absent => false - Present { value: r } => (l.kind == r.kind) && (l.children == r.children) + match find_node_record(store: left, identity: identity) { + NodeRecordAbsent { identity: _ } => false + NodeRecordIsAuthoredSource { identity: _ } => false + NodeRecordFound(l) => + match find_node_record(store: right, identity: identity) { + NodeRecordAbsent { identity: _ } => false + NodeRecordIsAuthoredSource { identity: _ } => false + NodeRecordFound(r) => (l.kind == r.kind) && (l.children == r.children) } } } From 8b57788a769ba1369dcbfafdef92e711cdc5ea64 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 05:32:26 +0000 Subject: [PATCH 02/25] Apply the authored-source tagging rule to both arms, and make the invariant executable Review 58068's nit was right and is worth more than a reply. `AuthoredSourceEmpty` hashed its tag directly through `content_hash_atom` while `AuthoredSourceText` went through `content_hash_tagged_structural`, so the tagging invariant stated in the comment directly above held for one arm and not the other -- an untagged digest sitting inside a family whose whole claim is that its members are tagged. A stated invariant with one exception is worse than no invariant, and the exception was the arm a reader is least likely to check. Both arms now apply the same combinator under different tags. The empty arm's payload is its own tag, which is degenerate deliberately: what the payload must NOT be is some file's content, because then an empty file and a file holding that content would collide BY CONSTRUCTION rather than by accident. Distinct tags keep the arms apart; the shared combinator is what makes "tagged" true of the family rather than of most of it. The invariant is now executed rather than asserted in prose: `no_authored_identity_is_an_untagged_digest` checks that neither arm's identity equals the bare atom digest of its own tag. It is discriminating -- against the revision this commit replaces it goes red, which is what separates it from a restatement of the comment. Green, along with re-runs of the two identity claims the change could have disturbed. This changes the empty file's identity. Nothing writes authored-source objects yet -- the format landed one commit ago and has no producer outside the witnesses -- so there is no document population to migrate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT --- dag/gunbc/scm/object_store.dag | 32 ++++++++++++++++++- ...cm_object_store_collision_witness_test.dag | 7 ++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index e123c5a8d33..cf2fc1953a7 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -103,9 +103,26 @@ fn authored_source_content(text: String) -> AuthoredSourceContent { } } +// ONE RULE, APPLIED TWICE, AND THE FIRST CUT DID NOT DO THAT. The empty arm hashed its tag directly +// through content_hash_atom while the text arm went through content_hash_tagged_structural, so the +// tagging invariant the comment above states held for one arm and not the other -- an untagged +// digest sitting in a family whose whole claim is that its members are tagged. Named by external +// review 58068 on gunbc#9891 as a consistency nit; it is fixed rather than argued because a stated +// invariant with one exception is worse than no invariant, and the exception is the arm a reader is +// least likely to check. +// +// Both arms now apply the SAME combinator under DIFFERENT tags. The empty arm's payload is its own +// tag, which is degenerate and deliberately so: what the payload must not be is some file's content, +// because then an empty file and a file holding that content would collide BY CONSTRUCTION rather +// than by accident. Distinct tags are what keep the two arms apart, and the shared combinator is +// what makes "tagged" true of the family rather than of most of it. fn authored_source_identity(content: AuthoredSourceContent) -> ObjectId { match content { - AuthoredSourceEmpty => content_hash_atom(value: authored_source_empty_tag) + AuthoredSourceEmpty => + content_hash_tagged_structural( + tag: authored_source_empty_tag, + payload: content_hash_atom(value: authored_source_empty_tag), + ) AuthoredSourceText(text) => content_hash_tagged_structural( tag: authored_source_text_tag, @@ -759,3 +776,16 @@ fn the_same_file_stores_cleanly_under_its_own_identity() -> Bool { Stored { store: store, identity: _ } => store_object_count(store: store) == 1 } } + +// THE TAGGING INVARIANT, EXECUTED RATHER THAN ASSERTED IN PROSE. Every authored identity is the +// tagged combination, so no arm's identity equals the bare atom digest of its own tag. This is the +// discriminating claim for review 58068's nit: against the first cut, where the empty arm WAS that +// bare digest, it goes red -- which is what makes it evidence rather than a restatement of the +// comment above authored_source_identity. +fn every_authored_identity_is_tagged() -> Bool { + let empty = authored_source_identity(content: AuthoredSourceEmpty) + let text = authored_source_identity(content: authored_source_content(text: "hello\n")) + !object_id_eq(left: empty, right: content_hash_atom(value: authored_source_empty_tag)) + && !object_id_eq(left: text, right: content_hash_atom(value: authored_source_text_tag)) + && !object_id_eq(left: empty, right: text) +} diff --git a/dag/test/claim/scm/scm_object_store_collision_witness_test.dag b/dag/test/claim/scm/scm_object_store_collision_witness_test.dag index 1f6da56f7b9..5ef5dc1e23a 100644 --- a/dag/test/claim/scm/scm_object_store_collision_witness_test.dag +++ b/dag/test/claim/scm/scm_object_store_collision_witness_test.dag @@ -24,6 +24,7 @@ import gunbc.scm.object_store { the_empty_file_is_its_own_object, a_file_under_a_nodes_locator_refuses, the_same_file_stores_cleanly_under_its_own_identity, + every_authored_identity_is_tagged, } // One locator, two different canonical records -- refused, and the refusal names both records so a @@ -84,3 +85,9 @@ test fn a_file_under_a_nodes_locator_is_refused() -> Bool { test fn the_file_fixture_stores_cleanly_on_its_own() -> Bool { the_same_file_stores_cleanly_under_its_own_identity() } + +// Both authored arms derive through the same tagged combinator, so neither identity is the bare atom +// digest of its own tag. Red against the revision where the empty arm skipped the tagging. +test fn no_authored_identity_is_an_untagged_digest() -> Bool { + every_authored_identity_is_tagged() +} From 921a90c6d58914b9c1c5a8f47e2db1bef92bbc50 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 08:00:13 +0000 Subject: [PATCH 03/25] Execute the authored-source checkout refusal instead of arguing it from the call graph Side-chat review cautioned that my reachability answer might not hold: authored objects decode, but a CHECKOUT might never be able to LAND on one, because every edge the encoder emits was derived from a node record. That caution is right about the encoder and wrong about the decoder, and the difference is worth executing rather than reasoning about -- a call-graph argument is not evidence that two halves meet. The decoder resolves {"target": {"at": ""}} through the position map, and that map now holds authored entries alongside node entries. So a document may name a file as a node's CHILD -- an input no encoder would ever emit, and exactly the kind this repository does not control. The witness builds that document by hand: position 0 is the file, position 1 is a node whose only child targets position 0, root is position 1. Children-before-parents is the order the decoder requires, so the document is well-formed by the format's own rules -- it is not malformed, it is legitimate and says something the model must refuse to reconstruct. The document LOADS (which is what makes this a checkout claim rather than another decoder claim) and the checkout then refuses naming the authored source. It is red against a checkout reporting MissingObject for a file that is present -- the two have opposite remedies -- and red against one that reconstructs the node with the child dropped. Paired with the control that makes the claim about the FILE rather than the document shape: the identical shape with a node at position 0 checks out. Without it, a decoder refusing this shape for any reason would satisfy the claim. One fixture bug found and fixed while writing this: the parent node initially targeted position 1 (itself) rather than position 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT --- ...cm_commit_closure_json_v2_witness_test.dag | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index f4e9f903cda..98d2690cdb2 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -76,6 +76,8 @@ import gunbc.scm.checkout { checkout, CheckedOut, CheckoutRefused, + MissingObject, + ObjectIsAuthoredSource, } import gunbc.scm.commit_closure { CommitClosure } import gunbc.scm.object_table_json { @@ -835,3 +837,147 @@ test fn scm_image_an_atom_carrying_its_identity_still_loads() -> Bool { + +// ------------------------------------------------------------------------------------------------ +// A DOCUMENT CAN POINT A PROGRAM'S EDGE AT A FILE, AND THAT IS THE PRODUCTION PATH TO THE +// AUTHORED-SOURCE REFUSAL. +// +// gunbc#9891 added `ObjectIsAuthoredSource` to checkout, and the reachability argument for it was a +// CALL-GRAPH argument: checkout_object is called from production, and the decoder is a production +// producer of authored objects, therefore the arm is production-reachable. Side-chat review cautioned +// that the two halves might not meet -- authored objects decode, but a CHECKOUT might never be able +// to land on one, because every edge the ENCODER emits was derived from a node record. That caution +// is correct about the encoder and wrong about the decoder, and the difference is worth executing +// rather than reasoning about. +// +// The decoder resolves `{"target": {"at": ""}}` through the position map, and the position map now +// holds authored entries alongside node entries. So a document may name an authored entry as a node's +// CHILD, which no encoder would ever emit and which is exactly the input this repository does not +// control. The claim below builds that document by hand and checks that checkout refuses with the +// authored-source cause rather than with MissingObject -- the two have opposite remedies, and the +// object in question is present. +fn scm_image_authored_entry() -> JsonValue { + json_object(members: [json_kv(key: "source", value: json_string(s: "not a program\n"))]) +} + +fn scm_image_node_entry_targeting(position: String) -> JsonValue { + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv( + key: "children", + value: json_array( + elements: [ + json_object( + members: [ + json_kv(key: "label", value: json_string(s: "positional")), + json_kv( + key: "target", + value: json_object(members: [json_kv(key: "at", value: json_string(s: position))]) + ), + ] + ) + ] + ) + ), + ] + ) +} + +// Position 0 is the file, position 1 is a node whose only child targets position 0, and the root is +// position 1. Children-before-parents is the order the decoder requires, so this document is +// well-formed by the format's own rules -- it is not a malformed image, it is a legitimate one that +// says something the model must refuse to reconstruct. +fn scm_image_program_over_a_file_document() -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array( + elements: [scm_image_authored_entry(), scm_image_node_entry_targeting(position: "0")] + ) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) +} + +fn scm_image_checkout_cause_tag(loaded: ClosureDocLoadOutcome) -> String { + match loaded { + ClosureDocRefused { cause: _ } => "document_refused" + ClosureDocLoaded { store: store, root: root } => + match checkout(store: store, commit: commit_of_root(root: root)) { + CheckedOut { program: _ } => "checked_out" + CheckoutRefused { cause: c } => + match c { + MissingObject { identity: _ } => "missing" + ObjectIsAuthoredSource { identity: _ } => "authored_source" + } + } + } +} + +// THE DOCUMENT LOADS -- it is not refused at decode, which is the half that makes this a checkout +// claim rather than another decoder claim -- and the CHECKOUT then refuses naming the authored +// source. Against a checkout that reported MissingObject for a present file this goes red, and +// against one that reconstructed the node with the file dropped it goes red too. +test fn scm_image_a_child_edge_naming_a_file_refuses_at_checkout() -> Bool { + scm_image_checkout_cause_tag( + loaded: decode_closure_document(v: scm_image_program_over_a_file_document()) + ) == "authored_source" +} + +// CONTROL, and it is the one that makes the claim above about the FILE rather than about the +// document shape: the identical document with a node at position 0 instead of a file checks out. +// Without it, a decoder that refused this shape for any reason would satisfy the claim above. +fn scm_image_program_over_a_node_document() -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array( + elements: [ + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv(key: "children", value: json_array(elements: [])), + ] + ), + scm_image_node_entry_targeting(position: "0"), + ] + ) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) +} + +test fn scm_image_the_same_document_shape_checks_out_over_a_node() -> Bool { + scm_image_checkout_cause_tag( + loaded: decode_closure_document(v: scm_image_program_over_a_node_document()) + ) == "checked_out" +} From 22dfe3543f639d8e785b444e30c490944370ec35 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 10:02:31 +0000 Subject: [PATCH 04/25] Refuse for the right owner: split MemberFailed, and stop reporting a present file as missing Three defects the side-chat review located, each now carrying an executed discriminating claim plus a control: - `root_edge_for` mapped EVERY `CheckoutRefused` to `TargetChildMissing`, so a merge role naming a present authored-source object was reported as absent. `MergeRefusal` gains `TargetChildIsAuthoredSource { role, identity }` and the arm is selected by matching the checkout cause. - `decode_object_step` treated every `MemberFailed` as source-absent, so `{source: null}`, a duplicated `source`, and a non-object subject were all reported as a missing `kind` or an unexpected member. Only `MemberReadMissing` now selects the node arm; the other three refuse in their own right. - Two comment blocks claimed the empty payload and the arm tags carry a property they do not. `content_hash_tagged_structural` already domain-separates the arms by outer tag, and separating preimages is not separating 64-bit digests. The prose now states the weaker true claim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wy8wRfzTK2kvFzj9nAEbyT --- dag/gunbc/scm/commit_closure_json_v2.dag | 43 +++- dag/gunbc/scm/merge.dag | 25 +- dag/gunbc/scm/object_store.dag | 26 +- ...cm_commit_closure_json_v2_witness_test.dag | 232 ++++++++++++++++++ dag/test/claim/scm/scm_merge_witness_test.dag | 73 ++++++ 5 files changed, 378 insertions(+), 21 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 60bb52c8a54..366266fc0ba 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -878,14 +878,41 @@ fn decode_object_step(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { acc: acc, cause: ClosureDocMemberReadRefused { cause: MemberReadWrongShape { key: "source" } } ) - MemberFailed { cause: _ } => - match member_set_refusal( - v: object_value, - allowed: ["kind", "children"], - context: ClosureDocObjectRecordMembers - ) { - Present { value: c } => decode_acc_refused(acc: acc, cause: c) - Absent => decode_object_members(acc: acc, object_value: object_value) + // ONLY *MISSING* IS EVIDENCE THAT THE ENTRY MIGHT BE A NODE, and the first cut of this + // classifier ignored that. It matched every MemberFailed cause and fell through to the node + // arm, so `{"source": null}`, a DUPLICATED source, and a source whose subject is not an + // object were all reclassified as "not a source" and then reported as a missing `kind` or + // an unexpected member. Each of those still refused, but for the WRONG OWNER -- the + // not-applicable-rendered-as-malformed failure this module's own header names, reintroduced + // by the classifier added to avoid it. gunbc.scm.json_member separates the four causes + // precisely so a consumer does not have to collapse them; collapsing them here threw that + // away. Found by external review of gunbc#9891. + MemberFailed { cause: c } => + match c { + MemberReadMissing { key: _ } => + match member_set_refusal( + v: object_value, + allowed: ["kind", "children"], + context: ClosureDocObjectRecordMembers + ) { + Present { value: set_cause } => decode_acc_refused(acc: acc, cause: set_cause) + Absent => decode_object_members(acc: acc, object_value: object_value) + } + MemberReadDuplicated { key: k } => + decode_acc_refused( + acc: acc, + cause: ClosureDocMemberReadRefused { cause: MemberReadDuplicated { key: k } } + ) + MemberReadWrongShape { key: k } => + decode_acc_refused( + acc: acc, + cause: ClosureDocMemberReadRefused { cause: MemberReadWrongShape { key: k } } + ) + MemberReadSubjectNotObject => + decode_acc_refused( + acc: acc, + cause: ClosureDocMemberReadRefused { cause: MemberReadSubjectNotObject } + ) } } } diff --git a/dag/gunbc/scm/merge.dag b/dag/gunbc/scm/merge.dag index f307b147bd5..2cbf97e479d 100644 --- a/dag/gunbc/scm/merge.dag +++ b/dag/gunbc/scm/merge.dag @@ -41,6 +41,8 @@ import gunbc.scm.checkout { CheckoutOutcome, CheckedOut, CheckoutRefused, + MissingObject, + ObjectIsAuthoredSource, checkout_object, } @@ -207,6 +209,7 @@ type MergeRefusal = TargetRootMissing { identity: ObjectId } | TargetRootIsAuthoredSource { identity: ObjectId } | TargetChildMissing { role: Symbol, identity: ObjectId } + | TargetChildIsAuthoredSource { role: Symbol, identity: ObjectId } | BindingLocatorCollision { role: Symbol, identity: ObjectId } | RequiredDependencyRemoved { dependency: MergeDependency, removed_role: Symbol } | DependencyKindUnsupportedForMerge { dependent: Symbol, required: Symbol, kind: Symbol } @@ -616,12 +619,30 @@ fn root_edge_step(store: ObjectStore, acc: MergeRootAcc, r: ResolvedRole) -> Mer } } +// EVERY CHECKOUT REFUSAL WAS COLLAPSED TO "MISSING", AND ONE OF THEM IS NOT MISSING. +// +// This mapped `CheckoutRefused { cause: _ }` to TargetChildMissing, which was total and correct while +// checkout had exactly one refusal. It stopped being correct the moment the store grew a second kind +// of object: a role whose value names an AUTHORED SOURCE is present, readable, and simply not a +// program, and reporting it as missing sends an operator looking for bytes that are right there. +// That is the same not-applicable-rendered-as-malformed failure the two kinds were introduced to +// avoid, reintroduced one layer up by a wildcard that used to be exhaustive. +// +// The wildcard is now a match on the cause, so a third checkout refusal cannot be absorbed either: +// it will fail to compile here instead of arriving as a fourth spelling of "missing". Found by +// external review of gunbc#9891, which is also the review that added the arm this consumer ignored. fn root_edge_for(store: ObjectStore, acc: MergeRootAcc, role: Symbol, object: ObjectId) -> MergeRootAcc { match checkout_object(store: store, identity: object) { - CheckoutRefused { cause: _ } => + CheckoutRefused { cause: cause } => MergeRootAcc { edges: acc.edges, - refusal: Present { value: TargetChildMissing { role: role, identity: object } }, + refusal: Present { + value: match cause { + MissingObject { identity: _ } => TargetChildMissing { role: role, identity: object } + ObjectIsAuthoredSource { identity: _ } => + TargetChildIsAuthoredSource { role: role, identity: object } + }, + }, } CheckedOut { program: program } => MergeRootAcc { diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index cf2fc1953a7..ce8f3bbe2e4 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -82,12 +82,13 @@ type AuthoredSourceContent = AuthoredSourceEmpty | AuthoredSourceText(NonEmptyStr) -// THE TAGS ARE THE FAMILY SEPARATION, AND THEY ARE NOT DECORATION. Without them an authored source -// whose text digest happened to equal a node's structural digest would be indistinguishable at the -// locator, and the store would answer `contains` for the wrong kind of thing. Tagging does not make -// a collision impossible -- nothing at the declared fnv1a64 rung does -- it makes the two families -// disjoint by construction under the hash rule, so a collision between them is the same accident as -// a collision within one, and insert_derived refuses it identically. +// THE TAGS DOMAIN-SEPARATE THE PREIMAGES, AND THAT IS A WEAKER CLAIM THAN THE ONE THIS COMMENT +// FIRST MADE. It said tagging makes the families "disjoint by construction under the hash rule", +// which overstates what a 64-bit digest can deliver: the canonical PREIMAGES are separated, the +// OUTPUTS are not mathematically disjoint. So an honest collision between an authored source and a +// node remains possible at the declared fnv1a64 rung, exactly as a collision within one family +// does, and insert_object refuses it identically. Corrected on external review of gunbc#9891, which +// also disproved the payload rationale below. data authored_source_text_tag: NonEmptyStr = "gunbc.scm.authored_source.text" data authored_source_empty_tag: NonEmptyStr = "gunbc.scm.authored_source.empty" @@ -111,11 +112,14 @@ fn authored_source_content(text: String) -> AuthoredSourceContent { // invariant with one exception is worse than no invariant, and the exception is the arm a reader is // least likely to check. // -// Both arms now apply the SAME combinator under DIFFERENT tags. The empty arm's payload is its own -// tag, which is degenerate and deliberately so: what the payload must not be is some file's content, -// because then an empty file and a file holding that content would collide BY CONSTRUCTION rather -// than by accident. Distinct tags are what keep the two arms apart, and the shared combinator is -// what makes "tagged" true of the family rather than of most of it. +// WHAT THE EMPTY PAYLOAD IS NOT DOING, because the first repair claimed a reason that is false. +// It said the payload must not be some file's content, or an empty file and a file holding that +// content would collide BY CONSTRUCTION. That is wrong: content_hash_tagged_structural combines the +// OUTER TAG and the payload as distinct inputs, so the empty arm's preimage carries E and a text +// arm's carries T no matter what either payload is. The self-tag payload is therefore harmless and +// also unnecessary -- any fixed domain constant would do. It is kept because a constant that names +// the arm reads better than an arbitrary one, not because it prevents a collision. Disproved by +// external review of gunbc#9891, which constructed the inverse the original claim ruled out. fn authored_source_identity(content: AuthoredSourceContent) -> ObjectId { match content { AuthoredSourceEmpty => diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 98d2690cdb2..63cf5884232 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -26,6 +26,7 @@ import extdeps.languages.json.emit { json_array, json_object, json_string, + json_null, serialize_json, } import extdeps.languages.json.parse { @@ -80,6 +81,20 @@ import gunbc.scm.checkout { ObjectIsAuthoredSource, } import gunbc.scm.commit_closure { CommitClosure } +import gunbc.scm.merge { + MergeOutcome, + Merged, + MergeRefused, + MergeRolesContested, + TargetRootMissing, + TargetRootIsAuthoredSource, + TargetChildMissing, + TargetChildIsAuthoredSource, + BindingLocatorCollision, + RequiredDependencyRemoved, + DependencyKindUnsupportedForMerge, + merge, +} import gunbc.scm.object_table_json { ObjectTableUnknownKindTag, ObjectTableUnknownConnectiveTag, @@ -981,3 +996,220 @@ test fn scm_image_the_same_document_shape_checks_out_over_a_node() -> Bool { loaded: decode_closure_document(v: scm_image_program_over_a_node_document()) ) == "checked_out" } + +// ------------------------------------------------------------------------------------------------ +// THE SOURCE CLASSIFIER'S OWNERS, EXECUTED. +// +// The classifier that decides node-entry vs authored-source-entry first reads `source`. Its initial +// cut treated every failed read as "no source member, try the node arm", so a null source, a +// DUPLICATED source, and a non-object entry were each reported as a missing `kind` or an unexpected +// member -- refusing, but attributing the fault to the wrong owner. These claims pin the owner, not +// merely the refusal, which is the distinction this file's cause-tag reader exists to make. +fn scm_image_document_with_object_entry(entry: JsonValue) -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv(key: "objects", value: json_array(elements: [entry])), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "0"))]) + ), + ] + ) +} + +fn scm_image_entry_cause_tag(entry: JsonValue) -> String { + scm_image_cause_tag(loaded: decode_closure_document(v: scm_image_document_with_object_entry(entry: entry))) +} + +// A null `source` is a WRONG-SHAPED source member, not an absent one. +test fn scm_image_a_null_source_is_a_wrong_shaped_source() -> Bool { + scm_image_entry_cause_tag( + entry: json_object(members: [json_kv(key: "source", value: json_null())]) + ) == "member_read" +} + +// A DUPLICATED `source` refuses as duplicated rather than being read last-wins or reclassified as a +// node. This is the arm a naive reader accepts while holding two answers. +test fn scm_image_a_duplicated_source_refuses() -> Bool { + scm_image_entry_cause_tag( + entry: json_object( + members: [ + json_kv(key: "source", value: json_string(s: "one")), + json_kv(key: "source", value: json_string(s: "two")), + ] + ) + ) == "member_read" +} + +// AN ENTRY CARRYING BOTH SHAPES IS NOT ONE ARM. The source read succeeds, so the member set is +// checked against the authored-source set and `kind`/`children` are unexpected there. +test fn scm_image_an_entry_carrying_both_shapes_refuses() -> Bool { + scm_image_entry_cause_tag( + entry: json_object( + members: [ + json_kv(key: "source", value: json_string(s: "text")), + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv(key: "children", value: json_array(elements: [])), + ] + ) + ) == "unexpected_member" +} + +// CONTROL, and it is what makes the three claims above about the SOURCE MEMBER rather than about +// this document scaffold: an ordinary authored-source entry loads through the same scaffold. +test fn scm_image_an_ordinary_source_entry_loads() -> Bool { + scm_image_entry_cause_tag( + entry: json_object(members: [json_kv(key: "source", value: json_string(s: "text"))]) + ) == "loaded" +} + +// THE MERGE CHILD ARM, WITNESSED HERE BECAUSE THIS IS WHERE ITS INPUT CAN BE BUILT. +// +// `TargetChildIsAuthoredSource` is unreachable from a proposal: RequireBinding carries a `Node`, so +// every role value a proposal supplies is a semantic node by construction. It is reachable only when +// the TARGET ROOT's own child edge names a file -- which no encoder emits and only the decoder can +// produce, by resolving an edge position into an authored entry. So the same document the checkout +// claim above uses is merged rather than checked out, and the refusal must name the child's KIND. +// +// Before this PR's repair, root_edge_for mapped every checkout refusal to TargetChildMissing, so this +// returned "child_missing" for a file that is present. That is the discriminating red. +fn scm_image_merge_refusal_tag(o: MergeOutcome) -> String { + match o { + Merged { store: _, commit: _, roles: _ } => "merged" + MergeRolesContested { role: _, alternatives: _ } => "contested" + MergeRefused { cause: cause } => + match cause { + TargetRootMissing { identity: _ } => "root_missing" + TargetRootIsAuthoredSource { identity: _ } => "root_source" + TargetChildMissing { role: _, identity: _ } => "child_missing" + TargetChildIsAuthoredSource { role: _, identity: _ } => "child_source" + BindingLocatorCollision { role: _, identity: _ } => "collision" + RequiredDependencyRemoved { dependency: _, removed_role: _ } => "dependency" + DependencyKindUnsupportedForMerge { dependent: _, required: _, kind: _ } => "kind" + } + } +} + +// The child edge must be NAMED for merge to read it as a role, so this document differs from the +// checkout one in exactly that: label "named" rather than "positional". +fn scm_image_named_node_entry_targeting(position: String, name: String) -> JsonValue { + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv( + key: "children", + value: json_array( + elements: [ + json_object( + members: [ + json_kv(key: "label", value: json_string(s: "named")), + json_kv(key: "name", value: json_string(s: name)), + json_kv( + key: "target", + value: json_object(members: [json_kv(key: "at", value: json_string(s: position))]) + ), + ] + ) + ] + ) + ), + ] + ) +} + +fn scm_image_role_over_a_file_document() -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array( + elements: [ + scm_image_authored_entry(), + scm_image_named_node_entry_targeting(position: "0", name: "s"), + ] + ) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) +} + +fn scm_image_merge_tag_for(document: JsonValue) -> String { + match decode_closure_document(v: document) { + ClosureDocRefused { cause: _ } => "document_refused" + ClosureDocLoaded { store: store, root: root } => + scm_image_merge_refusal_tag( + o: merge( + store: store, + target: commit_of_root(root: root), + target_dependencies: [], + proposals: [], + ) + ) + } +} + +test fn scm_image_a_role_whose_value_is_a_file_refuses_as_authored_source() -> Bool { + scm_image_merge_tag_for(document: scm_image_role_over_a_file_document()) == "child_source" +} + +// CONTROL: the identical document with a NODE at position 0 merges, so the refusal above is about +// the child's kind rather than about a single-role target or an empty proposal list. +fn scm_image_role_over_a_node_document() -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array( + elements: [ + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv(key: "children", value: json_array(elements: [])), + ] + ), + scm_image_named_node_entry_targeting(position: "0", name: "s"), + ] + ) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) +} + +test fn scm_image_a_role_whose_value_is_a_node_merges() -> Bool { + scm_image_merge_tag_for(document: scm_image_role_over_a_node_document()) == "merged" +} diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 5bff60ed8cc..b48e390f96d 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -21,6 +21,7 @@ import gunbc.scm.object_store { LocatorCollision, empty_store, store_node, + store_authored_source, object_id_eq, } @@ -43,6 +44,7 @@ import gunbc.scm.merge { MergeRefused, MergeRefusal, TargetRootIsAuthoredSource, + TargetChildIsAuthoredSource, RequiredDependencyRemoved, DependencyKindUnsupportedForMerge, ResolvedRole, @@ -161,6 +163,7 @@ fn refusal_locates( TargetRootMissing { identity: _ } => false TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false + TargetChildIsAuthoredSource { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false DependencyKindUnsupportedForMerge { dependent: _, required: _, kind: _ } => false } @@ -285,6 +288,7 @@ test fn the_arm_a_dependency_row_is_actually_read() -> Bool { TargetRootMissing { identity: _ } => false TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false + TargetChildIsAuthoredSource { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false } Merged { store: _, commit: _, roles: _ } => false @@ -483,6 +487,7 @@ test fn an_unsupported_dependency_kind_refuses() -> Bool { TargetRootMissing { identity: _ } => false TargetRootIsAuthoredSource { identity: _ } => false TargetChildMissing { role: _, identity: _ } => false + TargetChildIsAuthoredSource { role: _, identity: _ } => false BindingLocatorCollision { role: _, identity: _ } => false } Merged { store: _, commit: _, roles: _ } => false @@ -604,3 +609,71 @@ test fn an_empty_proposal_set_preserves_the_target() -> Bool { outcome_is_merged(o: out) && role_holds_target_object(roles: merged_roles(o: out), role: ^f, expected: f_id) } + +// ------------------------------------------------------------------------------------------------ +// THE TWO WRONG-KIND ARMS, EXECUTED THROUGH THE PRODUCTION `merge` FUNCTION. +// +// Both arms were added to MergeRefusal when the store grew a second kind of object, and both were +// initially reachable only as match arms in this file's readers returning `false` -- imported to +// satisfy exhaustiveness and never produced by any input. That is the specification-without-execution +// shape DESIGN section 5 names, and an arm nothing constructs is indistinguishable from an arm that +// cannot be constructed. External review of gunbc#9891 called it, correctly. +// +// Each claim is paired with a control that substitutes a NODE at the same position, so the refusal is +// attributable to the object's KIND rather than to the fixture being unmergeable. +fn merge_witness_source_store() -> StoreOutcome { + store_authored_source(store: empty_store(), text: "not a program\n") +} + +fn merge_witness_source_fixture() -> Fixture { + let outcome = merge_witness_source_store() + Fixture { + store: store_or_empty(outcome: outcome), + target: Commit { root: identity_or(outcome: outcome, fallback: fallback_id()) }, + } +} + +fn merge_refusal_tag(o: MergeOutcome) -> String { + match o { + Merged { store: _, commit: _, roles: _ } => "merged" + MergeRolesContested { role: _, alternatives: _ } => "contested" + MergeRefused { cause: cause } => + match cause { + TargetRootMissing { identity: _ } => "root_missing" + TargetRootIsAuthoredSource { identity: _ } => "root_source" + TargetChildMissing { role: _, identity: _ } => "child_missing" + TargetChildIsAuthoredSource { role: _, identity: _ } => "child_source" + BindingLocatorCollision { role: _, identity: _ } => "collision" + RequiredDependencyRemoved { dependency: _, removed_role: _ } => "dependency" + DependencyKindUnsupportedForMerge { dependent: _, required: _, kind: _ } => "kind" + } + } +} + +// A MERGE TARGET WHOSE ROOT IS A FILE refuses naming the authored source, not TargetRootMissing -- +// the file is present, and telling an operator their target root is missing sends them looking for +// something that is there. +test fn merge_a_target_root_that_is_a_file_refuses_as_authored_source() -> Bool { + let fx = merge_witness_source_fixture() + merge_refusal_tag( + o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) + ) == "root_source" +} + +// CONTROL: the same shape with a NODE root merges, so the refusal above is about the kind and not +// about an empty proposal list or a single-object store. +test fn merge_a_target_root_that_is_a_node_does_not_refuse() -> Bool { + let fx = paired_fixture() + merge_refusal_tag( + o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) + ) == "merged" +} + +// WHERE THE CHILD ARM IS WITNESSED, AND WHY NOT HERE. A proposal cannot reach +// TargetChildIsAuthoredSource: `RequireBinding` carries a `Node`, so every role value a proposal +// supplies is stored through store_node and is a semantic node by construction. The only way a role +// value names a file is for the TARGET ROOT's own child edge to point at one, and the only producer +// of that shape is the decoder resolving an edge position into an authored entry. So that claim lives +// beside the document builder, in test.claim.scm_commit_closure_json_v2_witness, where the input can +// actually be constructed -- rather than here, where it would need a constructor the model correctly +// does not offer. From 7f92f9d53b3264b89d94723000aaf8733b11c240 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 10:32:09 +0000 Subject: [PATCH 05/25] Put every annotation above a subject, and ask the store for a NODE where a node is required Two things, both refusals the corpus already owns. PARSE. The floor refused this branch at the parse phase, not at a claim: gunbc.scm.commit_closure_json_v2 carried a nine-line rationale INSIDE decode_object_step's body, and test.claim.scm.scm_merge_witness_test ended with an eight-line block naming no subject at all. DESIGN 4c models annotations at module-item grain only, so both are unparseable rather than untidy -- and because parse refuses whole, the refusal took every SCM claim in the lane down with it, which is why nothing else in this slice was measured. The first block moves above decode_object_step. The second's subject is merge_refusal_tag: it explains why that function's TargetChildIsAuthoredSource arm has no claim in this file and where the claim that reaches it does live, so it sits above the enumeration it is about. THE ROOT ADMISSION. mint_repository_commit and gunbc.scm.log both asked store_contains, which is kind-agnostic by design -- it answers about the LOCATOR. So an authored-source object under a root locator minted a commit cleanly and then refused at checkout, and log reported it as `LoggedCommitRootHeld`: a row claiming a checkoutable root where checkout refuses. Both now ask find_node_record, whose three arms separate "nothing is here" from "a file is here"; the remedies differ (fetch the object vs. repair a wrong-kind reference), so they are separate arms rather than one Bool. LoggedCommitStanding grows LoggedCommitRootIsAuthoredSource and render says so in words. The claim pair is executed, not argued: a store holding exactly ONE object -- a file -- so RootMissing is unreachable and a kind-agnostic check mints; the refusal must carry the file's own identity back. Its control is one fact apart, a stored node in the same shape, and mints. Both PASS under claim_batch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure_json_v2.dag | 18 +++---- dag/gunbc/scm/log.dag | 28 +++++++++-- dag/gunbc/scm/render.dag | 2 + dag/gunbc/scm/repository_envelope.dag | 49 +++++++++++++------ .../scm/scm_checkout_verb_witness_test.dag | 2 + dag/test/claim/scm/scm_log_witness_test.dag | 4 ++ dag/test/claim/scm/scm_merge_witness_test.dag | 17 +++---- .../scm_repository_envelope_witness_test.dag | 48 ++++++++++++++++++ .../claim/scm/scm_status_witness_test.dag | 2 + 9 files changed, 131 insertions(+), 39 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 366266fc0ba..e5e7ec88fc8 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -854,6 +854,15 @@ fn decode_acc_refused(acc: DecodeAcc, cause: ClosureDocRefusal) -> DecodeAcc { // malformed failure. An entry carrying neither is refused by the node member set, which is the // pre-existing behaviour for an unrecognized entry and stays the default: a new shape has to be // added here deliberately, it cannot arrive by resembling something. +// ONLY *MISSING* IS EVIDENCE THAT THE ENTRY MIGHT BE A NODE, and the first cut of this +// classifier ignored that. It matched every MemberFailed cause and fell through to the node +// arm, so `{"source": null}`, a DUPLICATED source, and a source whose subject is not an +// object were all reclassified as "not a source" and then reported as a missing `kind` or +// an unexpected member. Each of those still refused, but for the WRONG OWNER -- the +// not-applicable-rendered-as-malformed failure this module's own header names, reintroduced +// by the classifier added to avoid it. gunbc.scm.json_member separates the four causes +// precisely so a consumer does not have to collapse them; collapsing them here threw that +// away. Found by external review of gunbc#9891. fn decode_object_step(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { match acc.refusal { Present { value: _ } => acc @@ -878,15 +887,6 @@ fn decode_object_step(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { acc: acc, cause: ClosureDocMemberReadRefused { cause: MemberReadWrongShape { key: "source" } } ) - // ONLY *MISSING* IS EVIDENCE THAT THE ENTRY MIGHT BE A NODE, and the first cut of this - // classifier ignored that. It matched every MemberFailed cause and fell through to the node - // arm, so `{"source": null}`, a DUPLICATED source, and a source whose subject is not an - // object were all reclassified as "not a source" and then reported as a missing `kind` or - // an unexpected member. Each of those still refused, but for the WRONG OWNER -- the - // not-applicable-rendered-as-malformed failure this module's own header names, reintroduced - // by the classifier added to avoid it. gunbc.scm.json_member separates the four causes - // precisely so a consumer does not have to collapse them; collapsing them here threw that - // away. Found by external review of gunbc#9891. MemberFailed { cause: c } => match c { MemberReadMissing { key: _ } => diff --git a/dag/gunbc/scm/log.dag b/dag/gunbc/scm/log.dag index d52391fe195..ad8f0bac027 100644 --- a/dag/gunbc/scm/log.dag +++ b/dag/gunbc/scm/log.dag @@ -19,7 +19,14 @@ module gunbc.scm.log // output failure DESIGN section 5 forbids at a user-facing boundary. import std.types { Bool, List, String } -import gunbc.scm.object_store { ObjectId, ObjectStore, store_contains } +import gunbc.scm.object_store { + NodeRecordAbsent, + NodeRecordFound, + NodeRecordIsAuthoredSource, + ObjectId, + ObjectStore, + find_node_record, +} import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommit } // WHETHER THIS COMMIT'S PROGRAM IS ACTUALLY HELD, which is decidable here and therefore reported. @@ -28,9 +35,19 @@ import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommit } // commit without saying so would present a row that cannot be checked out as though it were // ordinary -- the same reasoning that gives gunbc.scm.status its CheckedOutCommitMissing arm, and // the same refusal it is derived from. +// THREE STANDINGS, NOT TWO, BECAUSE THE STORE ANSWERS TWO DIFFERENT NEGATIVES. +// +// A commit root must denote a semantic node: it is the thing `checkout` reconstructs. The store also +// holds authored-source objects, so a locator can be PRESENT and still be the wrong kind of thing +// for a root. An earlier revision asked `store_contains`, which is deliberately kind-agnostic -- +// it answers about the LOCATOR -- and so reported a file sitting under a commit's root locator as +// `LoggedCommitRootHeld`: a log row claiming a checkoutable root where checkout refuses. The two +// negatives have different remedies (fetch the missing object vs. repair a wrong-kind reference), so +// they are separate arms rather than one Bool. type LoggedCommitStanding = LoggedCommitRootHeld | LoggedCommitRootMissingFromStore + | LoggedCommitRootIsAuthoredSource // The row exposes the commit's own opaque identity, not a value derived from its list position or // root. Consumers may hand it back as a reference; they must not inspect its ordinal for ordering or @@ -63,10 +80,10 @@ type CommitLogAcc { } fn logged_commit_standing(store: ObjectStore, root: ObjectId) -> LoggedCommitStanding { - if store_contains(store: store, identity: root) { - LoggedCommitRootHeld - } else { - LoggedCommitRootMissingFromStore + match find_node_record(store: store, identity: root) { + NodeRecordFound(_) => LoggedCommitRootHeld + NodeRecordAbsent { identity: _ } => LoggedCommitRootMissingFromStore + NodeRecordIsAuthoredSource { identity: _ } => LoggedCommitRootIsAuthoredSource } } @@ -126,6 +143,7 @@ fn commit_log_is_fully_held(log: CommitLog) -> Bool { match r.standing { LoggedCommitRootHeld => true LoggedCommitRootMissingFromStore => false + LoggedCommitRootIsAuthoredSource => false } } else { false diff --git a/dag/gunbc/scm/render.dag b/dag/gunbc/scm/render.dag index a7e64eea42b..1cddf48f652 100644 --- a/dag/gunbc/scm/render.dag +++ b/dag/gunbc/scm/render.dag @@ -56,6 +56,7 @@ import gunbc.scm.log { CommitLogRow, LoggedCommitStanding, LoggedCommitRootHeld, + LoggedCommitRootIsAuthoredSource, LoggedCommitRootMissingFromStore, } import gunbc.scm.status { @@ -93,6 +94,7 @@ fn log_standing_suffix(standing: LoggedCommitStanding) -> String { match standing { LoggedCommitRootHeld => "" LoggedCommitRootMissingFromStore => " (program not held -- cannot be checked out)" + LoggedCommitRootIsAuthoredSource => " (root is a source file, not a program -- cannot be checked out)" } } diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 38a63e552b2..aa7c26d3469 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -80,7 +80,15 @@ import gunbc.scm.json_member { read_member_value, read_string_member, } -import gunbc.scm.object_store { ObjectId, ObjectStore, empty_store, store_contains } +import gunbc.scm.object_store { + NodeRecordAbsent, + NodeRecordFound, + NodeRecordIsAuthoredSource, + ObjectId, + ObjectStore, + empty_store, + find_node_record, +} import gunbc.scm.commit_closure { uncontained_targets } import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, @@ -268,6 +276,7 @@ type RepositoryCommitMint = RepositoryCommitMinted { repository: RepositoryEnvelope, reference: RepositoryCommitRef } | RepositoryCommitMintAllocatorInvalid { next_ordinal: Int } | RepositoryCommitMintRootMissing { root: ObjectId } + | RepositoryCommitMintRootIsAuthoredSource { root: ObjectId } | RepositoryCommitMintParentMissing { parent: RepositoryCommitRef } // PURE CONSTRUCTION WALL FOR INTERNAL HISTORIES. The allocator is carried by the repository and @@ -275,6 +284,11 @@ type RepositoryCommitMint // highest row would then reuse an identity and let an old user-held reference resolve confidently to // a different commit. A descendant accepts only an EXISTING parent, so construction order makes a // cycle unwritable internally. This function performs no persistence, CLI action, or host effect. +// +// THE ROOT ADMISSION ASKS FOR A NODE, NOT FOR A LOCATOR. `store_contains` answers about the locator +// and is kind-agnostic by design, so asking it here admitted an authored-source object as a commit +// root -- a commit that mints cleanly and then refuses at checkout, which is the wrong place for the +// refusal to appear and the wrong owner to name in it. fn mint_repository_commit( repository: RepositoryEnvelope, root: ObjectId, @@ -283,28 +297,31 @@ fn mint_repository_commit( ) -> RepositoryCommitMint { if repository.commit_allocator.next_ordinal < 0 { RepositoryCommitMintAllocatorInvalid { next_ordinal: repository.commit_allocator.next_ordinal } - } else if !store_contains(store: repository.store, identity: root) { - RepositoryCommitMintRootMissing { root: root } } else { - match parent { - Present { value: parent_ref } => - match commit_at_reference(commits: repository.commits, reference: parent_ref) { - Absent => RepositoryCommitMintParentMissing { parent: parent_ref } - Present { value: _ } => + match find_node_record(store: repository.store, identity: root) { + NodeRecordAbsent { identity: _ } => RepositoryCommitMintRootMissing { root: root } + NodeRecordIsAuthoredSource { identity: _ } => RepositoryCommitMintRootIsAuthoredSource { root: root } + NodeRecordFound(_) => + match parent { + Present { value: parent_ref } => + match commit_at_reference(commits: repository.commits, reference: parent_ref) { + Absent => RepositoryCommitMintParentMissing { parent: parent_ref } + Present { value: _ } => + mint_repository_commit_admitted( + repository: repository, + root: root, + message: message, + ancestry: DescendsFrom { parent: parent_ref }, + ) + } + Absent => mint_repository_commit_admitted( repository: repository, root: root, message: message, - ancestry: DescendsFrom { parent: parent_ref }, + ancestry: RootCommit, ) } - Absent => - mint_repository_commit_admitted( - repository: repository, - root: root, - message: message, - ancestry: RootCommit, - ) } } } diff --git a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag index 4b3b6237de4..938c2278e03 100644 --- a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag +++ b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag @@ -28,6 +28,7 @@ import gunbc.scm.repository_envelope { RepositoryCommitMinted, RepositoryCommitMintAllocatorInvalid, RepositoryCommitMintParentMissing, + RepositoryCommitMintRootIsAuthoredSource, RepositoryCommitMintRootMissing, mint_repository_commit, } @@ -104,6 +105,7 @@ fn scm_ck_minted_or(repo: RepositoryEnvelope, root: ObjectId, message: String, p RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo RepositoryCommitMintParentMissing { parent: _ } => repo RepositoryCommitMintRootMissing { root: _ } => repo + RepositoryCommitMintRootIsAuthoredSource { root: _ } => repo } } diff --git a/dag/test/claim/scm/scm_log_witness_test.dag b/dag/test/claim/scm/scm_log_witness_test.dag index 06e5cc6491f..dde7a3b77e0 100644 --- a/dag/test/claim/scm/scm_log_witness_test.dag +++ b/dag/test/claim/scm/scm_log_witness_test.dag @@ -17,6 +17,7 @@ import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommitMinted, RepositoryCommitMintAllocatorInvalid, + RepositoryCommitMintRootIsAuthoredSource, RepositoryCommitMintRootMissing, RepositoryCommitMintParentMissing, mint_repository_commit, @@ -29,6 +30,7 @@ import gunbc.scm.log { CommitLogRows, CommitLogRow, LoggedCommitRootHeld, + LoggedCommitRootIsAuthoredSource, LoggedCommitRootMissingFromStore, } @@ -72,6 +74,7 @@ fn log_witness_mint(repo: RepositoryEnvelope, root: ObjectId, message: String, p RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo RepositoryCommitMintRootMissing { root: _ } => repo + RepositoryCommitMintRootIsAuthoredSource { root: _ } => repo RepositoryCommitMintParentMissing { parent: _ } => repo } } @@ -176,6 +179,7 @@ test fn a_commit_whose_root_is_not_held_is_marked_rather_than_listed_as_ordinary map(rows, r => match r.standing { LoggedCommitRootHeld => "held" LoggedCommitRootMissingFromStore => "missing" + LoggedCommitRootIsAuthoredSource => "source" }) == ["held", "missing"] } } diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index b48e390f96d..119a444cf9f 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -633,6 +633,14 @@ fn merge_witness_source_fixture() -> Fixture { } } +// WHERE THE CHILD ARM IS WITNESSED, AND WHY NOT HERE. A proposal cannot reach +// TargetChildIsAuthoredSource: `RequireBinding` carries a `Node`, so every role value a proposal +// supplies is stored through store_node and is a semantic node by construction. The only way a role +// value names a file is for the TARGET ROOT's own child edge to point at one, and the only producer +// of that shape is the decoder resolving an edge position into an authored entry. So that claim lives +// beside the document builder, in test.claim.scm_commit_closure_json_v2_witness, where the input can +// actually be constructed -- rather than here, where it would need a constructor the model correctly +// does not offer. fn merge_refusal_tag(o: MergeOutcome) -> String { match o { Merged { store: _, commit: _, roles: _ } => "merged" @@ -668,12 +676,3 @@ test fn merge_a_target_root_that_is_a_node_does_not_refuse() -> Bool { o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) ) == "merged" } - -// WHERE THE CHILD ARM IS WITNESSED, AND WHY NOT HERE. A proposal cannot reach -// TargetChildIsAuthoredSource: `RequireBinding` carries a `Node`, so every role value a proposal -// supplies is stored through store_node and is a semantic node by construction. The only way a role -// value names a file is for the TARGET ROOT's own child edge to point at one, and the only producer -// of that shape is the decoder resolving an edge position into an authored entry. So that claim lives -// beside the document builder, in test.claim.scm_commit_closure_json_v2_witness, where the input can -// actually be constructed -- rather than here, where it would need a constructor the model correctly -// does not offer. diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 4442dc6d16d..f9ea8ce6c92 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -30,6 +30,7 @@ import gunbc.scm.object_store { ObjectId, ObjectStore, StoreOutcome, Stored, LocatorCollision, empty_store, object_id_eq, store_node, store_object_count, CopyOutcome, Copied, CopySourceAbsent, CopyLocatorCollision, copy_object, + store_authored_source, } import gunbc.scm.commit_closure { uncontained_targets } import gunbc.scm.ancestry { CommitAncestry, RootCommit, DescendsFrom, RepositoryCommitRef } @@ -54,6 +55,7 @@ import gunbc.scm.repository_envelope { encode_repository_checked, RepositoryCommitMint, RepositoryCommitMinted, RepositoryCommitMintAllocatorInvalid, RepositoryCommitMintParentMissing, + RepositoryCommitMintRootIsAuthoredSource, RepositoryCommitMintRootMissing, mint_repository_commit, commit_at_reference, } @@ -148,6 +150,7 @@ fn scm_env_minted_or(repo: RepositoryEnvelope, root: ObjectId, message: String, RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo RepositoryCommitMintParentMissing { parent: _ } => repo RepositoryCommitMintRootMissing { root: _ } => repo + RepositoryCommitMintRootIsAuthoredSource { root: _ } => repo } } @@ -1020,6 +1023,7 @@ test fn scm_env_the_mint_refuses_a_parent_not_already_in_the_repository() -> Boo RepositoryCommitMinted { repository: _, reference: _ } => false RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => false RepositoryCommitMintRootMissing { root: _ } => false + RepositoryCommitMintRootIsAuthoredSource { root: _ } => false } } @@ -1101,6 +1105,7 @@ test fn scm_env_deleting_the_highest_commit_does_not_reuse_its_identity() -> Boo RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => false RepositoryCommitMintParentMissing { parent: _ } => false RepositoryCommitMintRootMissing { root: _ } => false + RepositoryCommitMintRootIsAuthoredSource { root: _ } => false } } @@ -1129,3 +1134,46 @@ test fn scm_env_reordering_rows_does_not_change_which_commit_a_key_names() -> Bo } } } + +// THE ROOT ADMISSION DISCRIMINATES ON KIND, NOT ON PRESENCE, and this pair is what establishes that +// rather than the signature. The subject store holds exactly one object -- an authored source file -- +// so its locator is PRESENT: the `RootMissing` arm is not reachable here, and a kind-agnostic +// `store_contains` returns true and mints a commit. The claim asserts the file's own identity comes +// back in the refusal, so a refusal naming some other locator does not satisfy it. +test fn env_a_commit_root_that_is_a_source_file_refuses_as_authored_source() -> Bool { + match store_authored_source(store: empty_store(), text: "not a program") { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: store, identity: file } => + match mint_repository_commit( + repository: scm_env_repository_with_store(store: store), + root: file, + message: "root is a file", + parent: none, + ) { + RepositoryCommitMintRootIsAuthoredSource { root: r } => object_id_eq(left: r, right: file) + RepositoryCommitMinted { repository: _, reference: _ } => false + RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => false + RepositoryCommitMintParentMissing { parent: _ } => false + RepositoryCommitMintRootMissing { root: _ } => false + } + } +} + +// THE CONTROL, ONE FACT APART. Same shape, same store construction, same message; the root is a +// stored NODE instead of a stored file. It must mint. Without this, a mint that refused everything +// would satisfy the claim above. +test fn env_a_commit_root_that_is_a_node_mints() -> Bool { + let put = scm_env_put(store: empty_store(), n: scm_env_atom(identity: "program")) + match mint_repository_commit( + repository: scm_env_repository_with_store(store: put.store), + root: put.identity, + message: "root is a program", + parent: none, + ) { + RepositoryCommitMinted { repository: _, reference: _ } => true + RepositoryCommitMintRootIsAuthoredSource { root: _ } => false + RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => false + RepositoryCommitMintParentMissing { parent: _ } => false + RepositoryCommitMintRootMissing { root: _ } => false + } +} diff --git a/dag/test/claim/scm/scm_status_witness_test.dag b/dag/test/claim/scm/scm_status_witness_test.dag index 70178358e5b..c854e164a6d 100644 --- a/dag/test/claim/scm/scm_status_witness_test.dag +++ b/dag/test/claim/scm/scm_status_witness_test.dag @@ -10,6 +10,7 @@ import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommitMinted, RepositoryCommitMintAllocatorInvalid, + RepositoryCommitMintRootIsAuthoredSource, RepositoryCommitMintRootMissing, RepositoryCommitMintParentMissing, mint_repository_commit, @@ -78,6 +79,7 @@ fn status_witness_repository_with_checkout() -> RepositoryEnvelope { } RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => base RepositoryCommitMintRootMissing { root: _ } => base + RepositoryCommitMintRootIsAuthoredSource { root: _ } => base RepositoryCommitMintParentMissing { parent: _ } => base } } From ceb3c9fbd9646840dfd9f5268c5e5d05b0b55852 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 11:21:26 +0000 Subject: [PATCH 06/25] Brand a node reference so a file cannot inhabit a program position, and move the tag the schema outgrew TWO REVIEWS, ONE CUT. The designated SCM re-review's central finding was that ObjectId was ONE untyped locator standing in every position that requires a program -- StoredEdge.target, a commit's root -- so an authored-source identity inhabited those positions perfectly well and the model's answer was to notice afterwards: find_node_record at a mint, at a log row, at a checkout. That is validation standing exactly where construction was available (DESIGN 5). codex/gpt-5.6-sol, independently, refused the same PR for continuing to write the v2 closure format tag over a schema that grew a `source` member. Both are fixed here. THE REFERENCE TYPES. SemanticNodeObjectRef and AuthoredSourceObjectRef are sole_constructor over one ObjectId, so only the store mints one and no caller can brand a locator it merely holds; ScmObjectRef is the join for consumers that genuinely handle both. store_node hands back the node brand, store_authored_source the source brand -- two outcome types, because one shared outcome would force store_node's caller to match an arm store_node cannot produce. The admission rule is NOT duplicated: both route through insert_admission. A CHILD EDGE HAS TWO HONEST STATES, and one brand cannot carry both. A partial closure deliberately references objects it does not carry, so a decoder holds edges whose kind nothing has established. Branding those anyway would need a mint that records a CLAIM rather than evidence, and the only place to put it is an exported function -- the importable forge factory this module already recorded once. So NodeTarget names the state: NodeTargetResolved(SemanticNodeObjectRef), or NodeTargetUnresolved(ObjectId). Neither arm reads as the other. WHAT THE BRAND DOES NOT CLAIM: that the object is a node in THIS store. It does not carry which store, so checkout's two refusal arms remain as the honest boundary between stores. What it removes is the authoring mistake. CONSEQUENCES THE REVIEW DERIVED, each now structural rather than checked: closure completeness resolves a child edge instead of asking the kind-agnostic store_contains, so a file no longer counts as a contained child; mint_repository_commit takes a NodeTarget and stores the reference THE STORE handed back, so a RepositoryCommit's root cannot name a file; the decoder's position map carries ScmObjectRef, so a contained edge target or root resolving to an authored entry refuses at decode, owned by the layer reading the document, instead of building the store and failing at checkout. EVIDENCE MOVED WITH THE WALL rather than being deleted with it (DESIGN 4b(4)). The child-edge claim was a CHECKOUT claim; the same document now refuses at decode, so it asserts ObjectTableEdgeTargetIsAuthoredSource -- red against a decoder that resolves without discriminating, and equally red against one reporting the not-present cause for an entry that is right there. A root naming a file has its own claim and cause. merge's TargetChildIsAuthoredSource is still reachable, through an UNRESOLVED target, and its claim moves to the merge witness where insert_from_structure can build that store; its node control moves with it. THE TAGS. This module's own policy is that a schema growing a member grows a new format tag, so the closure document is v3 and the repository envelope v2 -- the latter for two reasons, the embedded table's new entry shape and root resolution at decode. The identity rule is a separate axis and it moved too: v1 named one derivation, and an authored source's identity comes from a different one. ONE tag each, not two: no durable v2 document exists, the only producer is a witness rebuilt every run, and a compatibility arm over an empty population is machinery with no consumer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/checkout.dag | 41 ++- dag/gunbc/scm/commit_closure.dag | 18 +- dag/gunbc/scm/commit_closure_json_v2.dag | 93 ++++- dag/gunbc/scm/log.dag | 6 +- dag/gunbc/scm/merge.dag | 20 +- dag/gunbc/scm/object_store.dag | 336 +++++++++++++----- dag/gunbc/scm/object_table_json.dag | 15 +- dag/gunbc/scm/repository_envelope.dag | 117 +++++- dag/gunbc/scm/status.dag | 6 +- .../scm/scm_checkout_verb_witness_test.dag | 11 +- ...cm_commit_closure_json_v2_witness_test.dag | 170 +++++++-- .../scm/scm_commit_closure_witness_test.dag | 75 +++- .../scm/scm_load_standing_witness_test.dag | 19 +- dag/test/claim/scm/scm_log_witness_test.dag | 13 +- dag/test/claim/scm/scm_merge_witness_test.dag | 121 ++++++- .../scm/scm_object_store_witness_test.dag | 79 ++-- .../claim/scm/scm_render_witness_test.dag | 30 +- .../scm_repository_envelope_witness_test.dag | 38 +- .../claim/scm/scm_status_witness_test.dag | 12 +- .../scm/scm_supersession_witness_test.dag | 4 +- 20 files changed, 939 insertions(+), 285 deletions(-) diff --git a/dag/gunbc/scm/checkout.dag b/dag/gunbc/scm/checkout.dag index 59428542599..17ba24183a9 100644 --- a/dag/gunbc/scm/checkout.dag +++ b/dag/gunbc/scm/checkout.dag @@ -14,6 +14,10 @@ import gunbc.scm.ancestry { RepositoryCommitRef } import gunbc.scm.object_store { ObjectId, ObjectRecord, + SemanticNodeObjectRef, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, ObjectStore, StoredEdge, NodeRecordFound, @@ -28,7 +32,7 @@ import gunbc.scm.object_store { // and a single timeline. Keeping the commit to its root is what lets two commits be compared, // deduplicated and reconstructed without consulting any history at all. type Commit { - root: ObjectId + root: NodeTarget } // AN AUTHORED SOURCE AT A PROGRAM POSITION IS ITS OWN REFUSAL, NOT A MISSING OBJECT. @@ -70,7 +74,7 @@ fn checkout_edge_step(store: ObjectStore, acc: CheckoutEdgeAcc, stored: StoredEd match acc.refusal { Present { value: _ } => acc Absent => - match checkout_object(store: store, identity: stored.target) { + match checkout_node_target(store: store, target: stored.target) { CheckoutRefused { cause: cause } => CheckoutEdgeAcc { edges: acc.edges, refusal: Present { value: cause } } CheckedOut { program: child } => @@ -82,6 +86,23 @@ fn checkout_edge_step(store: ObjectStore, acc: CheckoutEdgeAcc, stored: StoredEd } } +// AN UNCONTAINED TARGET IS RESOLVED HERE, NOT PREJUDGED ABSENT. The arm records that the DOCUMENT +// did not carry the object; it does not record that this store lacks it, and a graft may have +// supplied it since. So both arms go through the same lookup and the uncontained one keeps the two +// negatives apart -- absent, or a file -- exactly as the resolved arm does. +fn checkout_node_target(store: ObjectStore, target: NodeTarget) -> CheckoutOutcome { + match target { + NodeTargetResolved(reference) => checkout_object(store: store, identity: reference) + NodeTargetUnresolved(locator) => + match find_node_record(store: store, identity: locator) { + NodeRecordAbsent { identity: i } => CheckoutRefused { cause: MissingObject { identity: i } } + NodeRecordIsAuthoredSource { identity: i } => + CheckoutRefused { cause: ObjectIsAuthoredSource { identity: i } } + NodeRecordFound(record) => checkout_record(store: store, record: record) + } + } +} + fn checkout_record(store: ObjectStore, record: ObjectRecord) -> CheckoutOutcome { match fold( record.children, @@ -97,8 +118,14 @@ fn checkout_record(store: ObjectStore, record: ObjectRecord) -> CheckoutOutcome } } -fn checkout_object(store: ObjectStore, identity: ObjectId) -> CheckoutOutcome { - match find_node_record(store: store, identity: identity) { +// THE REFUSALS SURVIVE THE BRAND, AND SAYING WHY IS THE HONEST PART. A SemanticNodeObjectRef was +// minted against an object of node kind IN SOME STORE; it does not carry WHICH store. A reference +// minted against one store and resolved against another can therefore still find nothing, or find a +// file -- so these two arms are a live boundary between stores rather than a wall the brand already +// built. What the brand removes is the AUTHORING mistake: no caller can hand this function a +// locator it never resolved to a node at all. +fn checkout_object(store: ObjectStore, identity: SemanticNodeObjectRef) -> CheckoutOutcome { + match find_node_record(store: store, identity: identity.locator) { NodeRecordAbsent { identity: i } => CheckoutRefused { cause: MissingObject { identity: i } } NodeRecordIsAuthoredSource { identity: i } => CheckoutRefused { cause: ObjectIsAuthoredSource { identity: i } } @@ -113,7 +140,7 @@ fn checkout_object(store: ObjectStore, identity: ObjectId) -> CheckoutOutcome { // agreement, which is precisely the property that makes a monotonic store unable to name a program // on its own. fn checkout(store: ObjectStore, commit: Commit) -> CheckoutOutcome { - checkout_object(store: store, identity: commit.root) + checkout_node_target(store: store, target: commit.root) } // The rebuilt program carries OccurrenceSynthetic throughout, because occurrence identity is @@ -122,7 +149,7 @@ fn checkout(store: ObjectStore, commit: Commit) -> CheckoutOutcome { // and reconstruct identically. Round-trip equality is therefore asserted on content identity, never // on occurrence provenance -- and asserting it on provenance would be asserting the one property the // model says must not matter. -fn commit_of_root(root: ObjectId) -> Commit { +fn commit_of_root(root: NodeTarget) -> Commit { Commit { root: root } } @@ -190,7 +217,7 @@ fn checkout_repository_at(repo: RepositoryEnvelope, reference: RepositoryCommitR match commit_at_reference(commits: repo.commits, reference: reference) { Absent => CheckoutReferenceUnresolved { reference: reference } Present { value: selected } => - match checkout(store: repo.store, commit: commit_of_root(root: selected.root)) { + match checkout(store: repo.store, commit: commit_of_root(root: NodeTargetResolved(selected.root))) { CheckoutRefused { cause: cause } => CheckoutProgramUnreconstructable { reference: reference, cause: cause } CheckedOut { program: program } => diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index 6daa43106f5..50afcaf6e34 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -86,8 +86,10 @@ import gunbc.scm.object_store { AuthoredSourceObject, ObjectStore, StoredEdge, + NodeTarget, object_id_eq, - store_contains, + store_contains_node_target, + node_target_locator, store_records_in_dependency_order, } @@ -204,7 +206,7 @@ fn admitted_unresolved(admitted: AdmittedPartialCommitClosure) -> List // A closure is the store plus the root the commit names. Nothing about how it is written down. type CommitClosure { store: ObjectStore - root: ObjectId + root: NodeTarget } // WHY THE WIRE ARM IS "UNCONTAINED" AND THIS FUNCTION SAYS "UNRESOLVED", which is not two names for @@ -293,7 +295,11 @@ fn uncontained_in_record(store: ObjectStore, acc: List, record: Object record.children, init: acc, f: fn(inner, child) { - if store_contains(store: store, identity: child.target) { inner } else { concat([child.target], inner) } + if store_contains_node_target(store: store, target: child.target) { + inner + } else { + concat([node_target_locator(target: child.target)], inner) + } } ) } @@ -302,7 +308,11 @@ fn uncontained_in_record(store: ObjectStore, acc: List, record: Object // incompleteness there is -- a document that names a commit whose top object it does not carry. // The predecessor encoded the root positionally too, so this case was as unnameable as the rest. fn unresolved_root(closure: CommitClosure) -> List { - if store_contains(store: closure.store, identity: closure.root) { [] } else { [closure.root] } + if store_contains_node_target(store: closure.store, target: closure.root) { + [] + } else { + [node_target_locator(target: closure.root)] + } } fn closure_is_complete(closure: CommitClosure) -> Bool { diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index e5e7ec88fc8..0401cf25d16 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -16,6 +16,8 @@ import gunbc.scm.object_table_json { ObjectTableUnknownBehaviorTag, ObjectTableUnknownLabelTag, ObjectTableEdgeTargetUnresolved, + ObjectTableEdgeTargetIsAuthoredSource, + ObjectTableEdgeTargetIsAuthoredSource, ObjectTableObjectCollision, object_table_load_standing, } @@ -114,9 +116,19 @@ import gunbc.scm.object_store { AuthoredSourceEmpty, AuthoredSourceText, StoredEdge, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, + node_target_locator, + ScmObjectRef, + SemanticNodeRef, + AuthoredSourceRef, StoreOutcome, Stored, LocatorCollision, + SourceStoreOutcome, + SourceStored, + SourceLocatorCollision, empty_store, insert_from_structure, store_authored_source, @@ -166,6 +178,7 @@ type ClosureDocRefusal = ClosureDocMemberReadRefused { cause: MemberReadRefusal } | ClosureDocFormatUnrecognized { found: String } | ClosureDocRootPositionUnresolved { reference: String } + | ClosureDocRootIsAuthoredSource { reference: String } | ClosureDocUnexpectedMember { context: ClosureDocMemberContext, key: String } | ClosureDocTargetReferenceRefused { cause: TargetReferenceRefusal } | ClosureDocObjectTableRefused { cause: ObjectTableDecodeRefusal } @@ -223,10 +236,24 @@ fn member_set_refusal(v: JsonValue, allowed: List, context: ClosureDocMe } type ClosureDocLoadOutcome - = ClosureDocLoaded { store: ObjectStore, root: ObjectId } + = ClosureDocLoaded { store: ObjectStore, root: NodeTarget } | ClosureDocRefused { cause: ClosureDocRefusal } -data closure_document_format_tag: String = "gunbc-scm-commit-closure-v2" +// THE TAG MOVES BECAUSE THE SCHEMA GREW A MEMBER, WHICH IS THIS MODULE'S OWN STATED POLICY. +// +// The comment above says it in as many words: the format tag means "I understand this EXACT schema", +// additive evolution within a version is deliberately foreclosed, and a schema that grows a member +// grows a NEW FORMAT TAG. The object table now admits `{"source": ...}` entries, which a reader of +// the v2 schema refuses. Continuing to write "โ€ฆ-v2" over that would have been a meaning fork under +// DESIGN section 3 -- one name, two materially different contracts -- and it would have been THIS +// module's policy broken by this module. Named by external review of gunbc#9891. +// +// ONE TAG, NOT TWO, AND THE REASON IS THAT NO v2 DOCUMENT EXISTS TO BE COMPATIBLE WITH. A decoder +// that supported both would say so by admitting both tags; there is nothing to admit. The only +// producer of these bytes is a witness rebuilt from source every run, so there is no durable +// artifact whose v2 spelling has to keep loading, and inventing a compatibility arm for an empty +// population would be machinery with no consumer. +data closure_document_format_tag: String = "gunbc-scm-commit-closure-v3" // WHAT THIS MODULE IS AND IS NOT, so the next layer is not built on a wrong assumption. // @@ -392,7 +419,10 @@ fn encode_edge(positions: EncodePositions, e: StoredEdge) -> JsonValue { json_object( members: concat( encode_label(l: e.label), - [json_kv(key: "target", value: encode_target(positions: positions, target: e.target))] + [json_kv( + key: "target", + value: encode_target(positions: positions, target: node_target_locator(target: e.target)), + )] ) ) } @@ -560,9 +590,9 @@ fn an_uncontained_key(missing: List) -> String { }) } -fn encode_closure_document(store: ObjectStore, root: ObjectId) -> JsonValue { +fn encode_closure_document(store: ObjectStore, root: NodeTarget) -> JsonValue { let acc = encode_object_table(store: store) - let root_reference = encoded_root_reference(acc: acc, root: root) + let root_reference = encoded_root_reference(acc: acc, root: node_target_locator(target: root)) json_object( members: [ json_kv(key: "format", value: json_string(s: closure_document_format_tag)), @@ -680,9 +710,14 @@ fn decode_kind(kind_value: JsonValue) -> KindDecode { // is O(n log n); the refusal semantics are unchanged, because a reference still resolves ONLY // against positions already decoded -- the map is populated in the same forward order the list was, // so a forward or fabricated reference is still absent and still refuses. -type DecodePositions = Map - -fn resolve_reference(positions: DecodePositions, reference: String) -> ObjectId? { +// THE POSITION MAP CARRIES THE KIND, WHICH IS WHY A FILE CANNOT ARRIVE AS A CHILD. It held a bare +// ObjectId, so a resolved edge target came back as an address and the decoder had nothing to +// discriminate on -- an entry that decoded to an authored source was written into a node's child +// edge and only failed much later, at checkout, as a refusal owned by the wrong layer. Storing the +// branded reference makes the kind available exactly where the document is being believed. +type DecodePositions = Map + +fn resolve_reference(positions: DecodePositions, reference: String) -> ScmObjectRef? { map_lookup(m: positions, key: reference) } @@ -803,16 +838,27 @@ fn decode_edge_step(positions: DecodePositions, acc: EdgeAcc, edge_value: JsonVa } TargetUncontained { digest: identity } => EdgeAcc { - edges: concat([StoredEdge { label: label, target: identity }], acc.edges), + edges: concat( + [StoredEdge { label: label, target: NodeTargetUnresolved(identity) }], + acc.edges, + ), refusal: acc.refusal, } TargetContainedAt { reference: reference } => match resolve_reference(positions: positions, reference: reference) { - Present { value: identity } => + Present { value: SemanticNodeRef(node) } => EdgeAcc { - edges: concat([StoredEdge { label: label, target: identity }], acc.edges), + edges: concat( + [StoredEdge { label: label, target: NodeTargetResolved(node) }], + acc.edges, + ), refusal: acc.refusal, } + Present { value: AuthoredSourceRef(_) } => + EdgeAcc { + edges: acc.edges, + refusal: Present { value: ClosureDocObjectTableRefused { cause: ObjectTableEdgeTargetIsAuthoredSource { reference: reference } } }, + } Absent => EdgeAcc { edges: acc.edges, @@ -924,17 +970,21 @@ fn decode_object_step(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { // derived rather than what the document said. fn decode_authored_source(acc: DecodeAcc, text: String) -> DecodeAcc { match store_authored_source(store: acc.store, text: text) { - LocatorCollision { identity: i, existing: _, incoming: _ } => + SourceLocatorCollision { identity: i, existing: _, incoming: _ } => DecodeAcc { store: acc.store, positions: acc.positions, next: acc.next, refusal: Present { value: ClosureDocObjectTableRefused { cause: ObjectTableObjectCollision { identity: i } } }, } - Stored { store: next_store, identity: identity } => + SourceStored { store: next_store, source: source } => DecodeAcc { store: next_store, - positions: map_insert(m: acc.positions, key: position_reference(n: acc.next), value: identity), + positions: map_insert( + m: acc.positions, + key: position_reference(n: acc.next), + value: AuthoredSourceRef(source), + ), next: acc.next + 1, refusal: acc.refusal, } @@ -977,13 +1027,13 @@ fn decode_object_members(acc: DecodeAcc, object_value: JsonValue) -> DecodeAcc { next: acc.next, refusal: Present { value: ClosureDocObjectTableRefused { cause: ObjectTableObjectCollision { identity: i } } }, } - Stored { store: next_store, identity: identity } => + Stored { store: next_store, node: node } => DecodeAcc { store: next_store, positions: map_insert( m: acc.positions, key: position_reference(n: acc.next), - value: identity, + value: SemanticNodeRef(node), ), next: acc.next + 1, refusal: acc.refusal, @@ -1064,10 +1114,14 @@ fn decode_closure_document_body(v: JsonValue) -> ClosureDocLoadOutcome { match decode_target_reference(target_value: root_value) { TargetReferenceRefused { cause: c } => ClosureDocRefused { cause: ClosureDocTargetReferenceRefused { cause: c } } - TargetUncontained { digest: root } => ClosureDocLoaded { store: store, root: root } + TargetUncontained { digest: root } => + ClosureDocLoaded { store: store, root: NodeTargetUnresolved(root) } TargetContainedAt { reference: reference } => match resolve_reference(positions: positions, reference: reference) { - Present { value: root } => ClosureDocLoaded { store: store, root: root } + Present { value: SemanticNodeRef(root) } => + ClosureDocLoaded { store: store, root: NodeTargetResolved(root) } + Present { value: AuthoredSourceRef(_) } => + ClosureDocRefused { cause: ClosureDocRootIsAuthoredSource { reference: reference } } Absent => ClosureDocRefused { cause: ClosureDocRootPositionUnresolved { reference: reference } } } @@ -1143,6 +1197,7 @@ fn closure_document_load_standing(cause: ClosureDocRefusal) -> LoadStanding { } ClosureDocUnexpectedMember { context: _, key: _ } => LoadDocumentMalformed ClosureDocRootPositionUnresolved { reference: _ } => LoadDocumentMalformed + ClosureDocRootIsAuthoredSource { reference: _ } => LoadDocumentMalformed ClosureDocObjectTableRefused { cause: c } => object_table_load_standing(cause: c) ClosureDocTargetReferenceRefused { cause: c } => match c { @@ -1164,7 +1219,7 @@ fn closure_document_load_standing(cause: ClosureDocRefusal) -> LoadStanding { // predecessor format could not produce: a document whose uncontained references leave objects the // store does not hold loads correctly and is PARTIAL, which is a success with an obligation rather // than a failure. Completeness is derived from the objects, never a flag beside them. -fn loaded_closure_standing(store: ObjectStore, root: ObjectId) -> LoadStanding { +fn loaded_closure_standing(store: ObjectStore, root: NodeTarget) -> LoadStanding { if closure_is_complete(closure: CommitClosure { store: store, root: root }) { LoadComplete } else { diff --git a/dag/gunbc/scm/log.dag b/dag/gunbc/scm/log.dag index ad8f0bac027..92e257f16e2 100644 --- a/dag/gunbc/scm/log.dag +++ b/dag/gunbc/scm/log.dag @@ -23,8 +23,8 @@ import gunbc.scm.object_store { NodeRecordAbsent, NodeRecordFound, NodeRecordIsAuthoredSource, - ObjectId, ObjectStore, + SemanticNodeObjectRef, find_node_record, } import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommit } @@ -79,8 +79,8 @@ type CommitLogAcc { rows: List } -fn logged_commit_standing(store: ObjectStore, root: ObjectId) -> LoggedCommitStanding { - match find_node_record(store: store, identity: root) { +fn logged_commit_standing(store: ObjectStore, root: SemanticNodeObjectRef) -> LoggedCommitStanding { + match find_node_record(store: store, identity: root.locator) { NodeRecordFound(_) => LoggedCommitRootHeld NodeRecordAbsent { identity: _ } => LoggedCommitRootMissingFromStore NodeRecordIsAuthoredSource { identity: _ } => LoggedCommitRootIsAuthoredSource diff --git a/dag/gunbc/scm/merge.dag b/dag/gunbc/scm/merge.dag index 2cbf97e479d..dd395e161d3 100644 --- a/dag/gunbc/scm/merge.dag +++ b/dag/gunbc/scm/merge.dag @@ -21,6 +21,10 @@ import v2.std.dependency { import gunbc.scm.object_store { ObjectId, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, + node_target_locator, ObjectStore, StoreOutcome, Stored, @@ -43,7 +47,7 @@ import gunbc.scm.checkout { CheckoutRefused, MissingObject, ObjectIsAuthoredSource, - checkout_object, + checkout_node_target, } // A REQUIREMENT IS AUTHORED, AND ABSENCE IS ONE OF THE THINGS THAT CAN BE AUTHORED. This is the @@ -304,7 +308,7 @@ fn edge_label_role(label: EdgeLabel) -> Symbol? { // preservation law mean object reuse: nothing is recomputed for a role no proposal implicates, so // "unchanged" is the same object and not a fresh derivation that hashes equal. fn target_roles(store: ObjectStore, target: Commit) -> TargetReadOutcome { - match find_node_record(store: store, identity: target.root) { + match find_node_record(store: store, identity: node_target_locator(target: target.root)) { NodeRecordAbsent { identity: i } => TargetUnreadable { cause: TargetRootMissing { identity: i } } NodeRecordIsAuthoredSource { identity: i } => TargetUnreadable { cause: TargetRootIsAuthoredSource { identity: i } } @@ -316,7 +320,7 @@ fn target_roles(store: ObjectStore, target: Commit) -> TargetReadOutcome { Present { value: role } => concat([ResolvedRole { role: role, - value: PreservedFromTarget { object: stored.target }, + value: PreservedFromTarget { object: node_target_locator(target: stored.target) }, }], acc) } })), @@ -462,10 +466,10 @@ fn normalize_requirement(store: ObjectStore, req: Requirement) -> NormalizeOutco NormalizeRefused { cause: BindingLocatorCollision { role: role, identity: identity }, } - Stored { store: store, identity: identity } => + Stored { store: store, node: node } => Normalized { store: store, - desired: DesiredBindingPresent { object: identity }, + desired: DesiredBindingPresent { object: node.locator }, } } } @@ -632,7 +636,7 @@ fn root_edge_step(store: ObjectStore, acc: MergeRootAcc, r: ResolvedRole) -> Mer // it will fail to compile here instead of arriving as a fourth spelling of "missing". Found by // external review of gunbc#9891, which is also the review that added the arm this consumer ignored. fn root_edge_for(store: ObjectStore, acc: MergeRootAcc, role: Symbol, object: ObjectId) -> MergeRootAcc { - match checkout_object(store: store, identity: object) { + match checkout_node_target(store: store, target: NodeTargetUnresolved(object)) { CheckoutRefused { cause: cause } => MergeRootAcc { edges: acc.edges, @@ -759,10 +763,10 @@ fn finish_merge( MergeRefused { cause: BindingLocatorCollision { role: ^commit_root, identity: identity }, } - Stored { store: store, identity: identity } => + Stored { store: store, node: node } => Merged { store: store, - commit: Commit { root: identity }, + commit: Commit { root: NodeTargetResolved(node) }, roles: applied.roles, } } diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index ce8f3bbe2e4..606e7b554a3 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -52,13 +52,92 @@ import v2.std.node { // conversion exists to get them out of step. type ObjectId = Hash +// THE LOCATOR IS NOT THE REFERENCE, AND CONFLATING THEM WAS THE MODEL'S CENTRAL DEFECT. +// +// `ObjectId` above is one untyped address. While it was also the type standing in every position +// that REQUIRES A PROGRAM -- a node's child edge, a commit's root -- an authored-source identity +// inhabited those positions perfectly well, and the model's answer was to notice afterwards: +// find_node_record at a mint, at a log row, at a checkout, each one detecting a wrong kind that the +// type had already admitted. That is validation standing exactly where construction was available, +// which DESIGN section 5 names as the tell that a check is a second representation of a constraint +// the model should carry. Found by external review of gunbc#9891, which also named the shape. +// +// So a REFERENCE is branded by the kind of object it was minted against, and only this module can +// mint one: `sole_constructor` refuses cross-module construction and cast, so no caller can brand a +// locator it merely holds. The producers below hand back the brand they derived -- store_node a +// node reference, store_authored_source a source reference -- and every node-requiring position +// takes the node brand. A file in a child edge is then not refused, it is UNWRITABLE. +// +// WHAT THE BRAND DOES NOT CLAIM, stated because a wrapper that overclaims is the hollow alias this +// repository rosters. It says: this locator was minted against an object of this kind IN SOME +// STORE. It does not carry which store, so a reference minted against one store and resolved +// against another can still find nothing, or find the other kind -- and checkout's two refusal arms +// therefore remain, honestly, as the boundary between stores. The brand removes the AUTHORING +// mistake, not the cross-store one, and the residual is a typed refusal rather than a silence. +// +// THE NAME `ObjectId` STAYS ON THE LOCATOR rather than becoming `ObjectLocator`. Its own declared +// rung note above already says what it is -- a 64-bit non-cryptographic locator, explicitly not a +// durable identity witness -- so the rename would move ~170 call sites to restate a fact the type +// already carries, and DESIGN section 2 prices churn that adds no concept as redundant work. +type SemanticNodeObjectRef sole_constructor { + locator: ObjectId +} + +type AuthoredSourceObjectRef sole_constructor { + locator: ObjectId +} + +// THE JOIN, so a consumer that genuinely handles both kinds -- persistence, a copy between stores, +// a resolver at a document boundary -- says so in one type instead of taking a bare locator and +// re-deciding. It is a sum rather than a supertype for the reason ScmObject is: a shared shape with +// an optional discriminator lets a consumer read past the distinction, which is the empty +// observation narrow. +type ScmObjectRef + = SemanticNodeRef(SemanticNodeObjectRef) + | AuthoredSourceRef(AuthoredSourceObjectRef) + +fn scm_object_ref_locator(reference: ScmObjectRef) -> ObjectId { + match reference { + SemanticNodeRef(r) => r.locator + AuthoredSourceRef(r) => r.locator + } +} + +// A CHILD EDGE HAS TWO HONEST STATES, AND ONE BRAND CANNOT CARRY BOTH. +// +// The obvious cut -- `target: SemanticNodeObjectRef` -- is wrong for a reason the partial-closure +// model makes unavoidable: a document may deliberately omit an object it references, so a decoder +// holds an edge whose target is NOT in the table and whose kind is therefore unknown. Branding it +// anyway would need a mint that records a CLAIM rather than evidence, and the only place to put +// that mint is an exported function -- the importable forge factory this module already recorded +// once, as the way a `sole_constructor` wall gets reopened by the helper written to prove it. +// +// So the target names which state it is in. A target resolved against a store IS a node reference +// and can never be a file; an UNRESOLVED target is a bare locator whose kind nothing has +// established yet, and every consumer that needs a program from it must resolve it and handle both +// negatives. Neither arm can be read as the other, and no forge exists in either direction. +// +// The arms are named for what is KNOWN rather than for where it came from. An earlier spelling said +// "uncontained", which is a fact about one DOCUMENT, and the same shape is needed by merge, whose +// role values are bare locators with no document anywhere in sight. +type NodeTarget + = NodeTargetResolved(SemanticNodeObjectRef) + | NodeTargetUnresolved(ObjectId) + +fn node_target_locator(target: NodeTarget) -> ObjectId { + match target { + NodeTargetResolved(reference) => reference.locator + NodeTargetUnresolved(locator) => locator + } +} + type StoredEdge { label: EdgeLabel - target: ObjectId + target: NodeTarget } type ObjectRecord sole_constructor { - identity: ObjectId + identity: SemanticNodeObjectRef kind: NodeKind children: List } @@ -87,7 +166,7 @@ type AuthoredSourceContent // which overstates what a 64-bit digest can deliver: the canonical PREIMAGES are separated, the // OUTPUTS are not mathematically disjoint. So an honest collision between an authored source and a // node remains possible at the declared fnv1a64 rung, exactly as a collision within one family -// does, and insert_object refuses it identically. Corrected on external review of gunbc#9891, which +// does, and insert_admission refuses it identically. Corrected on external review of gunbc#9891, which // also disproved the payload rationale below. data authored_source_text_tag: NonEmptyStr = "gunbc.scm.authored_source.text" data authored_source_empty_tag: NonEmptyStr = "gunbc.scm.authored_source.empty" @@ -136,7 +215,7 @@ fn authored_source_identity(content: AuthoredSourceContent) -> ObjectId { } type AuthoredSourceRecord sole_constructor { - identity: ObjectId + identity: AuthoredSourceObjectRef content: AuthoredSourceContent } @@ -150,9 +229,16 @@ type ScmObject | AuthoredSourceObject(AuthoredSourceRecord) fn scm_object_identity(object: ScmObject) -> ObjectId { + scm_object_ref_locator(reference: scm_object_ref(object: object)) +} + +// THE STORED OBJECT'S OWN BRANDED REFERENCE. This is the only route by which a caller outside this +// module obtains a brand for an object it did not itself store, and it derives the brand from the +// arm rather than from anything the caller supplied. +fn scm_object_ref(object: ScmObject) -> ScmObjectRef { match object { - SemanticNodeObject(record) => record.identity - AuthoredSourceObject(record) => record.identity + SemanticNodeObject(record) => SemanticNodeRef(record.identity) + AuthoredSourceObject(record) => AuthoredSourceRef(record.identity) } } @@ -188,7 +274,7 @@ type ObjectStore sole_constructor { // inferred from a field access has refused in this corpus with `no field ... on type 'T'`; routing // the element through a typed parameter is the spelling that resolves. fn labeled_child_of_stored(stored: StoredEdge) -> LabeledChildHash { - LabeledChildHash { label: stored.label, hash: stored.target } + LabeledChildHash { label: stored.label, hash: node_target_locator(target: stored.target) } } fn labeled_children_of_stored(children: List) -> List { @@ -256,6 +342,31 @@ fn find_node_record(store: ObjectStore, identity: ObjectId) -> NodeRecordLookup } } +// PRESENCE AT A NODE POSITION, WHICH IS NOT PRESENCE AT A LOCATOR. Completeness over a closure asks +// whether every child edge RESOLVES TO A PROGRAM in this store; a file sitting under that locator +// answers the locator question yes and the closure question no. Asking store_contains here counted a +// file as a contained child, which is one of the consequences external review derived from the +// untyped-locator defect. The brand carries the requirement; this carries the store-relative answer. +fn store_contains_node(store: ObjectStore, node: SemanticNodeObjectRef) -> Bool { + store_holds_node_at(store: store, locator: node.locator) +} + +fn store_holds_node_at(store: ObjectStore, locator: ObjectId) -> Bool { + match find_node_record(store: store, identity: locator) { + NodeRecordFound(_) => true + NodeRecordAbsent { identity: _ } => false + NodeRecordIsAuthoredSource { identity: _ } => false + } +} + +// A CHILD EDGE IS RESOLVED, NEVER READ. Both arms end at the same question -- does THIS store hold a +// semantic node under that locator -- and asking it here is what stops a consumer from treating the +// resolved arm as self-evidently present (it may name a node from another store) or the uncontained +// arm as self-evidently absent (a later graft may have supplied it). +fn store_contains_node_target(store: ObjectStore, target: NodeTarget) -> Bool { + store_holds_node_at(store: store, locator: node_target_locator(target: target)) +} + fn store_contains(store: ObjectStore, identity: ObjectId) -> Bool { match find_object(store: store, identity: identity) { Absent => false @@ -300,10 +411,41 @@ fn store_contains(store: ObjectStore, identity: ObjectId) -> Bool { // validation-standing-where-construction-was-available tell, because there is no single authority // from which the answer could have been derived instead -- both records are legitimate and the // question is whether they are the same one. +// TWO PRODUCERS, TWO OUTCOMES, BECAUSE THEY HAND BACK TWO DIFFERENT BRANDS. One shared outcome +// carrying an `ScmObjectRef` would force store_node's caller to match an authored-source arm that +// store_node cannot produce -- the same state-space widening the CopyOutcome note below refuses -- +// and the only way to avoid that would be to hand back a bare locator, which is the conflation +// these types exist to end. The admission decision itself is NOT duplicated: both route through +// insert_admission, so there is one rule about what a store does with an incoming object. type StoreOutcome - = Stored { store: ObjectStore, identity: ObjectId } + = Stored { store: ObjectStore, node: SemanticNodeObjectRef } | LocatorCollision { identity: ObjectId, existing: ScmObject, incoming: ScmObject } +type SourceStoreOutcome + = SourceStored { store: ObjectStore, source: AuthoredSourceObjectRef } + | SourceLocatorCollision { identity: ObjectId, existing: ScmObject, incoming: ScmObject } + +// WHAT A STORE DOES WITH AN INCOMING OBJECT, ASKED ONCE. Present-and-equal is idempotent, present- +// and-different is a refusal, absent is an append -- and the first two collapse into one arm here +// because the caller does nothing different with them: it hands back the brand it derived either +// way. This helper is what keeps the two branded producers above from being two copies of the +// admission rule (DESIGN section 3). +type InsertAdmission + = AdmitStored(ObjectStore) + | AdmitCollision { existing: ScmObject } + +fn insert_admission(store: ObjectStore, object: ScmObject) -> InsertAdmission { + match find_object(store: store, identity: scm_object_identity(object: object)) { + Absent => AdmitStored(ObjectStore { objects: concat(store.objects, [object]) }) + Present { value: existing } => + if objects_equal(left: existing, right: object) { + AdmitStored(store) + } else { + AdmitCollision { existing: existing } + } + } +} + // Record equality is the substrate's own structural equality over both fields. StoredEdge is a // label and a derived child identity, so comparing the child LISTS compares the whole subtree by // its already-derived identities -- no re-walk, no digest projection, no hand-written pairwise fold. @@ -330,40 +472,41 @@ fn objects_equal(left: ScmObject, right: ScmObject) -> Bool { } } -fn insert_object(store: ObjectStore, object: ScmObject) -> StoreOutcome { - let identity = scm_object_identity(object: object) - match find_object(store: store, identity: identity) { - Absent => - Stored { - store: ObjectStore { objects: concat(store.objects, [object]) }, - identity: identity, - } - Present { value: existing } => - if objects_equal(left: existing, right: object) { - Stored { store: store, identity: identity } - } else { - LocatorCollision { identity: identity, existing: existing, incoming: object } +fn insert_derived(store: ObjectStore, record: ObjectRecord) -> StoreOutcome { + let object = SemanticNodeObject(record) + match insert_admission(store: store, object: object) { + AdmitStored(next) => Stored { store: next, node: record.identity } + AdmitCollision { existing: existing } => + LocatorCollision { + identity: record.identity.locator, + existing: existing, + incoming: object, } } } -fn insert_derived(store: ObjectStore, record: ObjectRecord) -> StoreOutcome { - insert_object(store: store, object: SemanticNodeObject(record)) -} - // THE AUTHORED-SOURCE PRODUCER, AND IT DERIVES ITS IDENTITY EXACTLY AS THE OTHER TWO DO. A caller // supplies bytes and nothing else; the identity follows from those bytes by the one rule above, so // there is no argument through which a caller could assert an identity it did not derive. This is // the same wall store_node and insert_from_structure stand behind, reached by a third producer // rather than by a second identity authority. -fn store_authored_source(store: ObjectStore, text: String) -> StoreOutcome { +fn store_authored_source(store: ObjectStore, text: String) -> SourceStoreOutcome { let content = authored_source_content(text: text) - insert_object( - store: store, - object: AuthoredSourceObject( - AuthoredSourceRecord { identity: authored_source_identity(content: content), content: content }, - ), + let reference = AuthoredSourceObjectRef { + locator: authored_source_identity(content: content), + } + let object = AuthoredSourceObject( + AuthoredSourceRecord { identity: reference, content: content }, ) + match insert_admission(store: store, object: object) { + AdmitStored(next) => SourceStored { store: next, source: reference } + AdmitCollision { existing: existing } => + SourceLocatorCollision { + identity: reference.locator, + existing: existing, + incoming: object, + } + } } // Grafting an entry between stores cannot forge, because it can only move a pairing some earlier @@ -380,8 +523,11 @@ fn store_authored_source(store: ObjectStore, text: String) -> StoreOutcome { // The type is separate from StoreOutcome rather than reusing it, because store_node cannot produce // a source-absent result. Folding the two together would force every store_node consumer to handle // an arm that cannot occur, which widens the state space instead of describing it. +// COPY CARRIES THE JOIN, NOT A BRAND, and that is the one place ScmObjectRef is the right answer +// rather than a way to dodge the distinction: a graft moves whatever the source store held, so the +// kind is discovered here and reported rather than required up front. type CopyOutcome - = Copied { store: ObjectStore, identity: ObjectId } + = Copied { store: ObjectStore, reference: ScmObjectRef } | CopySourceAbsent { identity: ObjectId } | CopyLocatorCollision { identity: ObjectId, existing: ScmObject, incoming: ScmObject } @@ -389,10 +535,14 @@ fn copy_object(from: ObjectStore, to: ObjectStore, identity: ObjectId) -> CopyOu match find_object(store: from, identity: identity) { Absent => CopySourceAbsent { identity: identity } Present { value: object } => - match insert_object(store: to, object: object) { - Stored { store: store, identity: i } => Copied { store: store, identity: i } - LocatorCollision { identity: i, existing: e, incoming: c } => - CopyLocatorCollision { identity: i, existing: e, incoming: c } + match insert_admission(store: to, object: object) { + AdmitStored(store) => Copied { store: store, reference: scm_object_ref(object: object) } + AdmitCollision { existing: existing } => + CopyLocatorCollision { + identity: identity, + existing: existing, + incoming: object, + } } } } @@ -462,10 +612,13 @@ fn store_node(store: ObjectStore, n: Node) -> StoreOutcome { value: LocatorCollision { identity: i, existing: e, incoming: c }, }, } - Stored { store: child_store, identity: child_identity } => + Stored { store: child_store, node: child_node } => StoreEdgeAcc { store: child_store, - edges: concat([StoredEdge { label: edge.label, target: child_identity }], acc.edges), + edges: concat( + [StoredEdge { label: edge.label, target: NodeTargetResolved(child_node) }], + acc.edges, + ), collision: none, } } @@ -510,10 +663,12 @@ fn insert_from_structure( insert_derived( store: store, record: ObjectRecord { - identity: content_hash_of_children( - kind: kind, - children: labeled_children_of_stored(children: children), - ), + identity: SemanticNodeObjectRef { + locator: content_hash_of_children( + kind: kind, + children: labeled_children_of_stored(children: children), + ), + }, kind: kind, children: children, }, @@ -564,15 +719,16 @@ fn collision_probe_parent(child: Node) -> Node { fn collision_probe_stored(outcome: StoreOutcome) -> ObjectStore { match outcome { - Stored { store: store, identity: _ } => store + Stored { store: store, node: _ } => store LocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } } -fn collision_probe_identity(outcome: StoreOutcome, fallback: ObjectId) -> ObjectId { +fn collision_probe_node(outcome: StoreOutcome, fallback: ObjectId) -> SemanticNodeObjectRef { match outcome { - Stored { store: _, identity: identity } => identity - LocatorCollision { identity: _, existing: _, incoming: _ } => fallback + Stored { store: _, node: node } => node + LocatorCollision { identity: _, existing: _, incoming: _ } => + SemanticNodeObjectRef { locator: fallback } } } @@ -594,17 +750,17 @@ fn collision_probe_identity(outcome: StoreOutcome, fallback: ObjectId) -> Object // both records so a caller can see what disagreed rather than merely that something did. fn a_different_record_under_one_locator_refuses() -> Bool { let real = store_node(store: empty_store(), n: collision_probe_atom(identity: ^collide_a)) - let identity = collision_probe_identity( + let node = collision_probe_node( outcome: real, fallback: content_hash(n: collision_probe_atom(identity: ^collide_a)), ) match insert_derived( store: collision_probe_stored(outcome: real), - record: ObjectRecord { identity: identity, kind: TypeNode { connective: Conj }, children: [] }, + record: ObjectRecord { identity: node, kind: TypeNode { connective: Conj }, children: [] }, ) { - Stored { store: _, identity: _ } => false + Stored { store: _, node: _ } => false LocatorCollision { identity: reported, existing: existing, incoming: incoming } => - object_id_eq(left: reported, right: identity) + object_id_eq(left: reported, right: node.locator) && !objects_equal(left: existing, right: incoming) } } @@ -616,7 +772,7 @@ fn the_same_record_under_one_locator_is_idempotent() -> Bool { let store = collision_probe_stored(outcome: real) match store_node(store: store, n: collision_probe_atom(identity: ^collide_a)) { LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: again, identity: _ } => + Stored { store: again, node: _ } => store_object_count(store: again) == store_object_count(store: store) } } @@ -626,14 +782,14 @@ fn the_same_record_under_one_locator_is_idempotent() -> Bool { // store would hand back a commit whose closure was never written. fn a_child_collision_propagates_and_the_parent_is_not_stored() -> Bool { let child = store_node(store: empty_store(), n: collision_probe_atom(identity: ^collide_child)) - let child_identity = collision_probe_identity( + let child_node = collision_probe_node( outcome: child, fallback: content_hash(n: collision_probe_atom(identity: ^collide_child)), ) let seeded = insert_derived( store: empty_store(), record: ObjectRecord { - identity: child_identity, + identity: child_node, kind: TypeNode { connective: Conj }, children: [], }, @@ -642,9 +798,9 @@ fn a_child_collision_propagates_and_the_parent_is_not_stored() -> Bool { store: collision_probe_stored(outcome: seeded), n: collision_probe_parent(child: collision_probe_atom(identity: ^collide_child)), ) { - Stored { store: _, identity: _ } => false + Stored { store: _, node: _ } => false LocatorCollision { identity: reported, existing: _, incoming: _ } => - object_id_eq(left: reported, right: child_identity) + object_id_eq(left: reported, right: child_node.locator) } } @@ -656,7 +812,7 @@ fn the_same_parent_stores_cleanly_without_the_impostor() -> Bool { n: collision_probe_parent(child: collision_probe_atom(identity: ^collide_child)), ) { LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: store, identity: _ } => store_object_count(store: store) == 2 + Stored { store: store, node: _ } => store_object_count(store: store) == 2 } } @@ -671,23 +827,23 @@ fn the_same_parent_stores_cleanly_without_the_impostor() -> Bool { // that copy_object does not always answer collision. fn a_copy_into_a_colliding_destination_refuses() -> Bool { let real = store_node(store: empty_store(), n: collision_probe_atom(identity: ^collide_copy)) - let identity = collision_probe_identity( + let node = collision_probe_node( outcome: real, fallback: content_hash(n: collision_probe_atom(identity: ^collide_copy)), ) let seeded = insert_derived( store: empty_store(), - record: ObjectRecord { identity: identity, kind: TypeNode { connective: Conj }, children: [] }, + record: ObjectRecord { identity: node, kind: TypeNode { connective: Conj }, children: [] }, ) match copy_object( from: collision_probe_stored(outcome: real), to: collision_probe_stored(outcome: seeded), - identity: identity, + identity: node.locator, ) { - Copied { store: _, identity: _ } => false + Copied { store: _, reference: _ } => false CopySourceAbsent { identity: _ } => false CopyLocatorCollision { identity: reported, existing: existing, incoming: incoming } => - object_id_eq(left: reported, right: identity) + object_id_eq(left: reported, right: node.locator) && !objects_equal(left: existing, right: incoming) } } @@ -706,15 +862,16 @@ fn authored_source_identity_follows_its_bytes() -> Bool { let two = store_authored_source(store: empty_store(), text: "hello\n") let other = store_authored_source(store: empty_store(), text: "goodbye\n") match one { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: _, identity: a } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: _, source: a } => match two { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: _, identity: b } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: _, source: b } => match other { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: _, identity: c } => - object_id_eq(left: a, right: b) && !object_id_eq(left: a, right: c) + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: _, source: c } => + object_id_eq(left: a.locator, right: b.locator) + && !object_id_eq(left: a.locator, right: c.locator) } } } @@ -723,11 +880,11 @@ fn authored_source_identity_follows_its_bytes() -> Bool { // STORING THE SAME FILE TWICE IS IDEMPOTENT, exactly as it is for a node -- content is fungible. fn the_same_authored_source_stores_once() -> Bool { match store_authored_source(store: empty_store(), text: "hello\n") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: first, identity: _ } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: first, source: _ } => match store_authored_source(store: first, text: "hello\n") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: again, identity: _ } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: again, source: _ } => (store_object_count(store: first) == 1) && (store_object_count(store: again) == 1) } } @@ -738,37 +895,38 @@ fn the_same_authored_source_stores_once() -> Bool { // narrow, and it would make `add` on an empty file silently do nothing. fn the_empty_file_is_its_own_object() -> Bool { match store_authored_source(store: empty_store(), text: "") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: store, identity: empty_identity } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: store, source: empty_source } => match store_authored_source(store: empty_store(), text: " ") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: _, identity: blank_identity } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: _, source: blank_source } => (store_object_count(store: store) == 1) - && !object_id_eq(left: empty_identity, right: blank_identity) + && !object_id_eq(left: empty_source.locator, right: blank_source.locator) } } } // A FILE UNDER A NODE'S LOCATOR REFUSES, WHICH IS THE ARM THE TWO-KIND STORE ADDS. objects_equal -// answers false across arms, so insert_object refuses rather than treating a present locator as +// answers false across arms, so insert_admission refuses rather than treating a present locator as // identity of content -- and a checkout that wanted the node would otherwise have been handed a // file. Built from an impostor because a real cross-kind fnv1a64 collision needs a preimage search: // what is under test is the arm, not the digest. fn a_file_under_a_nodes_locator_refuses() -> Bool { let real = store_node(store: empty_store(), n: collision_probe_atom(identity: ^collide_kind)) - let identity = collision_probe_identity( + let node = collision_probe_node( outcome: real, fallback: content_hash(n: collision_probe_atom(identity: ^collide_kind)), ) let content = authored_source_content(text: "not a program\n") - match insert_object( - store: collision_probe_stored(outcome: real), - object: AuthoredSourceObject(AuthoredSourceRecord { identity: identity, content: content }), - ) { - Stored { store: _, identity: _ } => false - LocatorCollision { identity: reported, existing: existing, incoming: incoming } => - object_id_eq(left: reported, right: identity) - && !objects_equal(left: existing, right: incoming) + let impostor = AuthoredSourceObject( + AuthoredSourceRecord { + identity: AuthoredSourceObjectRef { locator: node.locator }, + content: content, + }, + ) + match insert_admission(store: collision_probe_stored(outcome: real), object: impostor) { + AdmitStored(_) => false + AdmitCollision { existing: existing } => !objects_equal(left: existing, right: impostor) } } @@ -776,8 +934,8 @@ fn a_file_under_a_nodes_locator_refuses() -> Bool { // refusal is caused by the locator clash and not by authored source being unstorable. fn the_same_file_stores_cleanly_under_its_own_identity() -> Bool { match store_authored_source(store: empty_store(), text: "not a program\n") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: store, identity: _ } => store_object_count(store: store) == 1 + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: store, source: _ } => store_object_count(store: store) == 1 } } diff --git a/dag/gunbc/scm/object_table_json.dag b/dag/gunbc/scm/object_table_json.dag index fb531e5bcfb..7f95384a2f2 100644 --- a/dag/gunbc/scm/object_table_json.dag +++ b/dag/gunbc/scm/object_table_json.dag @@ -65,12 +65,15 @@ import gunbc.scm.load_standing { LoadIdentityCollision, } -// SIX CAUSES, TWO REMEDIES, AND THE SPLIT BETWEEN THEM IS THE WHOLE POINT OF THE TYPE. +// SEVEN CAUSES, TWO REMEDIES, AND THE SPLIT BETWEEN THEM IS THE WHOLE POINT OF THE TYPE. // -// Five of these say the document does not conform to a schema this build claims to understand: a tag +// Six of these say the document does not conform to a schema this build claims to understand: a tag // naming a kind, connective, behavior or label that is not in the closed vocabulary, or an edge -// target naming a position the document does not carry. Those are malformed bytes to a reader that -// should have understood them. +// target naming a position the document does not carry -- or naming one it DOES carry that holds an +// authored source where a child edge requires a program. Those are malformed bytes to a reader that +// should have understood them. The last is the one boundary at which a file can still be presented +// in a node position: inside the model the reference types make it unwritable, but a document is +// bytes and can say anything, so the wall there is a typed refusal rather than a constructor. // // ObjectTableObjectCollision is NOT that, and conflating it with the five is the error this type // exists to prevent. Two distinct records claiming one structural locator is an honest hash @@ -82,6 +85,7 @@ type ObjectTableDecodeRefusal | ObjectTableUnknownBehaviorTag { found: String } | ObjectTableUnknownLabelTag { found: String } | ObjectTableEdgeTargetUnresolved { reference: String } + | ObjectTableEdgeTargetIsAuthoredSource { reference: String } | ObjectTableObjectCollision { identity: ObjectId } // THE AUTHORITY THAT OWNS THE CAUSES OWNS THE STANDING DECISION, per DESIGN section 3: the dispatch @@ -89,7 +93,7 @@ type ObjectTableDecodeRefusal // shared vocabulary and the consumer sees only the standing. gunbc.scm.load_standing's header states // the same rule from the other side -- a new format is a new classifier beside its own refusals. // -// This match is exhaustive over the six and assigns per cause, never per group. The collision arm is +// This match is exhaustive over the seven and assigns per cause, never per group. The collision arm is // the load-bearing one: it must not reach LoadDocumentMalformed, because that is the only standing // permitting supersession. fn object_table_load_standing(cause: ObjectTableDecodeRefusal) -> LoadStanding { @@ -100,5 +104,6 @@ fn object_table_load_standing(cause: ObjectTableDecodeRefusal) -> LoadStanding { ObjectTableUnknownBehaviorTag { found: _ } => LoadDocumentMalformed ObjectTableUnknownLabelTag { found: _ } => LoadDocumentMalformed ObjectTableEdgeTargetUnresolved { reference: _ } => LoadDocumentMalformed + ObjectTableEdgeTargetIsAuthoredSource { reference: _ } => LoadDocumentMalformed } } diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index aa7c26d3469..eb57a93b312 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -86,6 +86,10 @@ import gunbc.scm.object_store { NodeRecordIsAuthoredSource, ObjectId, ObjectStore, + NodeTarget, + SemanticNodeObjectRef, + node_target_locator, + scm_object_ref_locator, empty_store, find_node_record, } @@ -128,7 +132,13 @@ import gunbc.scm.load_standing { // dissolve-on: a deterministic emission order -- a topological order with a content-digest tie-break // -- which is canonical because the digests are already content-derived. That is a real change to // encode_object_table rather than a flag, so it is a separate slice, not a footnote. -data repository_envelope_format_tag: String = "gunbc-scm-repository-v1" +// v2, FOR TWO CHANGES THAT ARE BOTH SCHEMA CHANGES BY THIS STACK'S OWN RULE. The embedded object +// table is gunbc.scm.commit_closure_json_v2's, which now admits authored-source entries, so a v1 +// reader refuses documents this writer can produce. And a commit's `root` is now RESOLVED against +// that table at decode -- a root the table does not carry, or carries as a file, refuses here rather +// than being admitted and noticed later -- so the set of documents that decode is different in both +// directions. Either change alone would move the tag. +data repository_envelope_format_tag: String = "gunbc-scm-repository-v2" // THE IDENTITY RULE IS VERSIONED SEPARATELY FROM THE SCHEMA, AND THE TWO ARE DIFFERENT AXES. // @@ -143,7 +153,13 @@ data repository_envelope_format_tag: String = "gunbc-scm-repository-v1" // deliberately NOT derived from the code that implements the rule: a tag computed from the current // implementation would agree with itself by construction and could never disagree, which is the // change-detector failure -- it would go green precisely when the rule changed. -data repository_node_identity_rule_tag: String = "fnv1a64-structural-canonical-v1" +// THE IDENTITY RULE MOVES ON ITS OWN AXIS, AND IT MOVED. v1 named ONE derivation: fnv1a64 over the +// canonicalized node structure. The store now derives a second family -- an authored source's +// identity, from content_hash_tagged_structural over a domain tag and the bytes -- and a document +// carrying both families is reconstructed by a rule v1 does not describe. A reader that accepted v1 +// here and then met a source entry would reconstruct identities by a rule the document never +// claimed, which is precisely the silent reinterpretation this tag exists to turn into a refusal. +data repository_node_identity_rule_tag: String = "fnv1a64-structural-canonical-v2" // ANCESTRY IS A COMMIT REFERENCE, NOT A NODE REFERENCE, and that is this module's own ruling reused // rather than a fresh choice. The annotation on `checked_out` records why it resolves against the @@ -159,7 +175,7 @@ data repository_node_identity_rule_tag: String = "fnv1a64-structural-canonical-v // wall that would become redundant at this cut. type RepositoryCommit sole_constructor { reference: RepositoryCommitRef - root: ObjectId + root: SemanticNodeObjectRef message: String ancestry: CommitAncestry } @@ -230,6 +246,8 @@ type RepositoryDecodeRefusal | RepositoryFormatUnrecognized { found: String } | RepositoryIdentityRuleUnrecognized { found: String } | RepositoryCommitRootReferenceUnresolved { reference: String } + | RepositoryCommitRootNotInObjectTable { root: ObjectId } + | RepositoryCommitRootIsAuthoredSource { root: ObjectId } | RepositoryCheckoutReferenceUnresolved { reference: RepositoryCommitRef } | RepositoryCommitReferenceDuplicatedOnWire { reference: RepositoryCommitRef } | RepositoryCommitParentNotPreviouslyAdmitted { parent: RepositoryCommitRef } @@ -285,23 +303,30 @@ type RepositoryCommitMint // a different commit. A descendant accepts only an EXISTING parent, so construction order makes a // cycle unwritable internally. This function performs no persistence, CLI action, or host effect. // +// THE MINT TAKES A TARGET AND HANDS BACK A BRANDED ROOT, which is the resolve-at-the-boundary shape +// the reference types are for. A caller may hold a bare locator -- from a document, from a fixture, +// from anywhere -- so requiring a SemanticNodeObjectRef here would have moved the problem rather +// than solved it. What the row stores is the reference THE STORE handed back for the record it +// actually found, never the caller's word for it, so a RepositoryCommit's root cannot name a file. +// // THE ROOT ADMISSION ASKS FOR A NODE, NOT FOR A LOCATOR. `store_contains` answers about the locator // and is kind-agnostic by design, so asking it here admitted an authored-source object as a commit // root -- a commit that mints cleanly and then refuses at checkout, which is the wrong place for the // refusal to appear and the wrong owner to name in it. fn mint_repository_commit( repository: RepositoryEnvelope, - root: ObjectId, + root: NodeTarget, message: String, parent: RepositoryCommitRef?, ) -> RepositoryCommitMint { if repository.commit_allocator.next_ordinal < 0 { RepositoryCommitMintAllocatorInvalid { next_ordinal: repository.commit_allocator.next_ordinal } } else { - match find_node_record(store: repository.store, identity: root) { - NodeRecordAbsent { identity: _ } => RepositoryCommitMintRootMissing { root: root } - NodeRecordIsAuthoredSource { identity: _ } => RepositoryCommitMintRootIsAuthoredSource { root: root } - NodeRecordFound(_) => + match find_node_record(store: repository.store, identity: node_target_locator(target: root)) { + NodeRecordAbsent { identity: r } => RepositoryCommitMintRootMissing { root: r } + NodeRecordIsAuthoredSource { identity: r } => + RepositoryCommitMintRootIsAuthoredSource { root: r } + NodeRecordFound(record) => match parent { Present { value: parent_ref } => match commit_at_reference(commits: repository.commits, reference: parent_ref) { @@ -309,7 +334,7 @@ fn mint_repository_commit( Present { value: _ } => mint_repository_commit_admitted( repository: repository, - root: root, + root: record.identity, message: message, ancestry: DescendsFrom { parent: parent_ref }, ) @@ -317,7 +342,7 @@ fn mint_repository_commit( Absent => mint_repository_commit_admitted( repository: repository, - root: root, + root: record.identity, message: message, ancestry: RootCommit, ) @@ -328,7 +353,7 @@ fn mint_repository_commit( fn mint_repository_commit_admitted( repository: RepositoryEnvelope, - root: ObjectId, + root: SemanticNodeObjectRef, message: String, ancestry: CommitAncestry, ) -> RepositoryCommitMint { @@ -399,7 +424,7 @@ fn encode_commit_record(acc: EncodeAcc, commit: RepositoryCommit) -> JsonValue { json_object( members: [ json_kv(key: "id", value: json_int(n: minted_id_ordinal(id: commit.reference.identity))), - json_kv(key: "root", value: encoded_root_reference(acc: acc, root: commit.root)), + json_kv(key: "root", value: encoded_root_reference(acc: acc, root: commit.root.locator)), json_kv(key: "message", value: json_string(s: commit.message)), json_kv(key: "ancestry", value: encode_ancestry(ancestry: commit.ancestry)), ] @@ -567,9 +592,9 @@ fn unresolved_commit_root_step(acc: EncodeAcc, found: ObjectId?, commit: Reposit match found { Present { value: _ } => found Absent => - match position_of(positions: acc.positions, identity: commit.root) { + match position_of(positions: acc.positions, identity: commit.root.locator) { Present { value: _ } => none - Absent => Present { value: commit.root } + Absent => Present { value: commit.root.locator } } } } @@ -703,11 +728,27 @@ type ResolvedCommitAcc { // authored-order fold admits each unique in-range key only after its parent, so duplicate, dangling, // forward and cyclic edges all refuse at the first unadmitted edge. The old all-start ancestry walk // was cubic; this keyed pass is linear in commits against the map. -fn resolve_pending_commits(pending: List, next_ordinal: Int) -> ResolvedCommitAcc { +// THE DOCUMENT'S ROOT LOCATOR BECOMES A NODE REFERENCE HERE, OR THE DOCUMENT REFUSES. +// +// This is the one boundary at which a file's locator can still arrive in a position that requires a +// program: inside the model the brand makes that unwritable, but a document is bytes and can say +// anything. So the store decoded from the same document is the authority that mints the brand, and +// the two negatives stay separate for the reason they do everywhere else -- an object table that +// does not carry the root at all is a different defect, with a different remedy, from one that +// carries a FILE under it. +// +// Refusing here rather than later is what makes the brand honest. Admitting the row with an +// unresolved root and leaving the encoder to notice would put a RepositoryEnvelope in a reader's +// hands whose commit root names nothing -- a value the type now claims cannot exist. +fn resolve_pending_commits( + store: ObjectStore, + pending: List, + next_ordinal: Int +) -> ResolvedCommitAcc { match fold( pending, init: ResolvedCommitAcc { commits: [], seen: empty_map(), refusal: none }, - f: fn(acc, c) { resolve_pending_step(next_ordinal: next_ordinal, acc: acc, c: c) } + f: fn(acc, c) { resolve_pending_step(store: store, next_ordinal: next_ordinal, acc: acc, c: c) } ) { ResolvedCommitAcc { commits: reversed, seen: seen, refusal: refusal } => ResolvedCommitAcc { commits: reverse(reversed), seen: seen, refusal: refusal } @@ -715,9 +756,43 @@ fn resolve_pending_commits(pending: List, next_ordinal: Int) -> R } fn resolve_pending_step( + store: ObjectStore, next_ordinal: Int, acc: ResolvedCommitAcc, c: PendingCommit +) -> ResolvedCommitAcc { + match acc.refusal { + Present { value: _ } => acc + Absent => + match find_node_record(store: store, identity: c.root) { + NodeRecordAbsent { identity: r } => + ResolvedCommitAcc { + commits: acc.commits, + seen: acc.seen, + refusal: Present { value: RepositoryCommitRootNotInObjectTable { root: r } }, + } + NodeRecordIsAuthoredSource { identity: r } => + ResolvedCommitAcc { + commits: acc.commits, + seen: acc.seen, + refusal: Present { value: RepositoryCommitRootIsAuthoredSource { root: r } }, + } + NodeRecordFound(record) => + resolve_pending_step_admitted( + next_ordinal: next_ordinal, + acc: acc, + c: c, + root: record.identity, + ) + } + } +} + +fn resolve_pending_step_admitted( + next_ordinal: Int, + acc: ResolvedCommitAcc, + c: PendingCommit, + root: SemanticNodeObjectRef ) -> ResolvedCommitAcc { match acc.refusal { Present { value: _ } => acc @@ -754,7 +829,7 @@ fn resolve_pending_step( commits: concat( [RepositoryCommit { reference: c.reference, - root: c.root, + root: root, message: c.message, ancestry: DescendsFrom { parent: parent }, }], @@ -769,7 +844,7 @@ fn resolve_pending_step( commits: concat( [RepositoryCommit { reference: c.reference, - root: c.root, + root: root, message: c.message, ancestry: RootCommit, }], @@ -823,7 +898,8 @@ fn decode_commit_root(positions: DecodePositions, root_value: JsonValue) -> Comm TargetUncontained { digest: root } => CommitRootDecoded { root: root } TargetContainedAt { reference: reference } => match resolve_reference(positions: positions, reference: reference) { - Present { value: root } => CommitRootDecoded { root: root } + Present { value: found } => + CommitRootDecoded { root: scm_object_ref_locator(reference: found) } Absent => CommitRootRefused { cause: RepositoryCommitRootReferenceUnresolved { reference: reference } } } @@ -1045,6 +1121,7 @@ fn decode_repository_body_with_allocator(v: JsonValue, next_ordinal: Int) -> Rep Present { value: c } => RepositoryDecodeRefused { cause: c } Absent => match resolve_pending_commits( + store: store, pending: reverse(reversed_commits), next_ordinal: next_ordinal, ) { @@ -1187,6 +1264,8 @@ fn repository_envelope_load_standing(cause: RepositoryDecodeRefusal) -> LoadStan TargetReferenceUncontainedDigestInvalid { found: _ } => LoadDocumentMalformed } RepositoryCommitRootReferenceUnresolved { reference: _ } => LoadDocumentMalformed + RepositoryCommitRootNotInObjectTable { root: _ } => LoadDocumentMalformed + RepositoryCommitRootIsAuthoredSource { root: _ } => LoadDocumentMalformed RepositoryCheckoutReferenceUnresolved { reference: _ } => LoadDocumentMalformed RepositoryCommitReferenceDuplicatedOnWire { reference: _ } => LoadDocumentMalformed RepositoryCommitParentNotPreviouslyAdmitted { parent: _ } => LoadDocumentMalformed diff --git a/dag/gunbc/scm/status.dag b/dag/gunbc/scm/status.dag index 9a8d1fe92db..cb2b75ba785 100644 --- a/dag/gunbc/scm/status.dag +++ b/dag/gunbc/scm/status.dag @@ -24,7 +24,7 @@ module gunbc.scm.status import std.types { Bool, Int, List, String } import std.minted_identity { minted_id_ordinal } import v2.std.node { Symbol } -import gunbc.scm.object_store { ObjectId } +import gunbc.scm.object_store { SemanticNodeObjectRef } import gunbc.scm.repository_envelope { RepositoryEnvelope, commit_at_reference } import gunbc.scm.merge { Requirement, @@ -46,7 +46,7 @@ import gunbc.scm.merge { // would state a commit exists when it may not. type StatusCheckout = NothingCheckedOut - | CheckedOutCommit { root: ObjectId, message: String } + | CheckedOutCommit { root: SemanticNodeObjectRef, message: String } | CheckedOutCommitMissing { reference: RepositoryCommitRef } // THREE STATES, NOT TWO, AND THE THIRD IS THE ONE A SURFACE LOSES BY ACCIDENT. It is not enough that @@ -153,7 +153,7 @@ fn render_staged_role(staged: StagedRole) -> String { // a second naming scheme -- it repeats the one the model already publishes. // // THE SIBLING ARM IS DELIBERATELY UNCHANGED, and the asymmetry is the model's rather than this -// function's. `CheckedOutCommit` carries a root `ObjectId` and NO reference, so the only identity in +// function's. `CheckedOutCommit` carries a root node reference and NO commit reference, so the only identity in // reach is the content hash -- which `status_does_not_report_store_representation` states this // surface must not report, on the ground that below-boundary representation is opaque. Naming that // commit by its ordinal would require `StatusCheckout` to carry the reference, which is a change to diff --git a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag index 938c2278e03..0673d35ce3f 100644 --- a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag +++ b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag @@ -19,6 +19,9 @@ import gunbc.scm.object_store { store_node, Stored, LocatorCollision, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, } import gunbc.scm.repository_envelope { RepositoryEnvelope, @@ -71,14 +74,14 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // duplicates (#8707), which this layer's witness files already document hitting. type ScmCkPut { store: ObjectStore - identity: ObjectId + identity: NodeTarget } fn scm_ck_put(store: ObjectStore, n: Node) -> ScmCkPut { match store_node(store: store, n: n) { - Stored { store: s, identity: identity } => ScmCkPut { store: s, identity: identity } + Stored { store: s, node: node } => ScmCkPut { store: s, identity: NodeTargetResolved(node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - ScmCkPut { store: store, identity: identity } + ScmCkPut { store: store, identity: NodeTargetUnresolved(identity) } } } @@ -99,7 +102,7 @@ fn scm_ck_repository_with_store(store: ObjectStore) -> RepositoryEnvelope { } } -fn scm_ck_minted_or(repo: RepositoryEnvelope, root: ObjectId, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn scm_ck_minted_or(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 63cf5884232..b73fb75094c 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -59,6 +59,12 @@ import gunbc.scm.object_store { StoreOutcome, Stored, LocatorCollision, + SourceStored, + SourceLocatorCollision, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, + node_target_locator, empty_store, object_id_eq, store_node, @@ -101,6 +107,7 @@ import gunbc.scm.object_table_json { ObjectTableUnknownBehaviorTag, ObjectTableUnknownLabelTag, ObjectTableEdgeTargetUnresolved, + ObjectTableEdgeTargetIsAuthoredSource, ObjectTableObjectCollision, } import gunbc.scm.commit_closure_json_v2 { @@ -111,6 +118,7 @@ import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, ClosureDocFormatUnrecognized, ClosureDocRootPositionUnresolved, + ClosureDocRootIsAuthoredSource, ClosureDocMemberReadRefused, ClosureDocUnexpectedMember, ClosureDocTargetReferenceRefused, @@ -168,14 +176,14 @@ fn scm_image_shared_program() -> Node { type ScmImagePut { store: ObjectStore - identity: ObjectId + identity: NodeTarget } fn scm_image_put(store: ObjectStore, n: Node) -> ScmImagePut { match store_node(store: store, n: n) { - Stored { store: s, identity: i } => ScmImagePut { store: s, identity: i } + Stored { store: s, node: node } => ScmImagePut { store: s, identity: NodeTargetResolved(node) } LocatorCollision { identity: i, existing: _, incoming: _ } => - ScmImagePut { store: store, identity: i } + ScmImagePut { store: store, identity: NodeTargetUnresolved(i) } } } @@ -216,6 +224,7 @@ fn scm_image_cause_tag(loaded: ClosureDocLoadOutcome) -> String { ClosureDocMemberReadRefused { cause: _ } => "member_read" ClosureDocFormatUnrecognized { found: _ } => "format" ClosureDocRootPositionUnresolved { reference: _ } => "root_position" + ClosureDocRootIsAuthoredSource { reference: _ } => "root_source" ClosureDocObjectTableRefused { cause: t } => match t { ObjectTableUnknownKindTag { found: _ } => "kind" @@ -223,6 +232,7 @@ fn scm_image_cause_tag(loaded: ClosureDocLoadOutcome) -> String { ObjectTableUnknownBehaviorTag { found: _ } => "behavior" ObjectTableUnknownLabelTag { found: _ } => "label" ObjectTableEdgeTargetUnresolved { reference: _ } => "edge_target" + ObjectTableEdgeTargetIsAuthoredSource { reference: _ } => "edge_target_source" ObjectTableObjectCollision { identity: _ } => "collision" } ClosureDocUnexpectedMember { context: _, key: _ } => "unexpected_member" @@ -427,7 +437,7 @@ test fn scm_image_an_edge_naming_a_later_position_refuses_as_the_edges_own_cause loaded: decode_closure_document( v: json_object( members: [ - json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v2")), + json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v3")), json_kv( key: "objects", value: json_array( @@ -474,7 +484,7 @@ test fn scm_image_an_unknown_connective_tag_refuses() -> Bool { loaded: decode_closure_document( v: json_object( members: [ - json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v2")), + json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v3")), json_kv( key: "objects", value: json_array( @@ -512,7 +522,7 @@ test fn scm_image_the_hand_authored_shape_itself_loads() -> Bool { loaded: decode_closure_document( v: json_object( members: [ - json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v2")), + json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v3")), json_kv( key: "objects", value: json_array( @@ -563,8 +573,12 @@ fn scm_image_encode_refused(outcome: ClosureDocEncodeOutcome) -> Bool { fn scm_image_orphan_parent_store() -> ScmImagePut { let full = scm_image_put(store: empty_store(), n: closure_doc_program()) - match copy_object(from: full.store, to: empty_store(), identity: full.identity) { - Copied { store: s, identity: i } => ScmImagePut { store: s, identity: i } + match copy_object( + from: full.store, + to: empty_store(), + identity: node_target_locator(target: full.identity), + ) { + Copied { store: s, reference: _ } => ScmImagePut { store: s, identity: full.identity } CopySourceAbsent { identity: _ } => ScmImagePut { store: empty_store(), identity: full.identity } CopyLocatorCollision { identity: _, existing: _, incoming: _ } => ScmImagePut { store: empty_store(), identity: full.identity } @@ -615,7 +629,7 @@ test fn scm_image_a_complete_closure_encodes() -> Bool { fn scm_image_identity_of(outcome: StoreOutcome, fallback: ObjectId) -> ObjectId { match outcome { - Stored { store: _, identity: i } => i + Stored { store: _, node: n } => n.locator LocatorCollision { identity: _, existing: _, incoming: _ } => fallback } } @@ -625,7 +639,11 @@ type ScmImageTwoAtoms { store: ObjectStore, first: ObjectId, second: ObjectId } fn scm_image_two_atoms() -> ScmImageTwoAtoms { let a = scm_image_put(store: empty_store(), n: closure_doc_atom(identity: ^A)) let b = scm_image_put(store: a.store, n: closure_doc_atom(identity: ^B)) - ScmImageTwoAtoms { store: b.store, first: a.identity, second: b.identity } + ScmImageTwoAtoms { + store: b.store, + first: node_target_locator(target: a.identity), + second: node_target_locator(target: b.identity), + } } fn scm_image_insert_children(kind: NodeKind, children: List, fallback: ObjectId) -> ObjectId { @@ -635,12 +653,16 @@ fn scm_image_insert_children(kind: NodeKind, children: List, fallbac ) } +// THE FIXTURE'S EDGES ARE UNRESOLVED TARGETS, and that is what a fixture holding only a locator can +// honestly say. It never resolved these addresses against a store, so it has no node reference to +// carry; the assembly rule under test consumes the locator either way, so the claim is unchanged +// while the fixture stops asserting a kind it did not establish. fn scm_image_named_stored(name: Symbol, target: ObjectId) -> StoredEdge { - StoredEdge { label: Named { name: name }, target: target } + StoredEdge { label: Named { name: name }, target: NodeTargetUnresolved(target) } } fn scm_image_positional_stored(target: ObjectId) -> StoredEdge { - StoredEdge { label: Positional, target: target } + StoredEdge { label: Positional, target: NodeTargetUnresolved(target) } } // NAMED children permuted: the SAME identity, and it is the identity the ordinary content_hash of @@ -716,7 +738,7 @@ fn scm_image_one_object_image(object_members: List, extra: List Bool { ] ) ), - json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v2")), + json_kv(key: "format", value: json_string(s: "gunbc-scm-commit-closure-v3")), ] ) ), @@ -944,14 +966,48 @@ fn scm_image_checkout_cause_tag(loaded: ClosureDocLoadOutcome) -> String { } } -// THE DOCUMENT LOADS -- it is not refused at decode, which is the half that makes this a checkout -// claim rather than another decoder claim -- and the CHECKOUT then refuses naming the authored -// source. Against a checkout that reported MissingObject for a present file this goes red, and -// against one that reconstructed the node with the file dropped it goes red too. -test fn scm_image_a_child_edge_naming_a_file_refuses_at_checkout() -> Bool { - scm_image_checkout_cause_tag( +// THE REFUSAL MOVED, AND THAT MOVE IS THE CLIMB THIS CLAIM NOW WITNESSES. +// +// It used to assert that the document LOADED and the CHECKOUT then refused naming the file. That was +// the honest reading of a model in which `StoredEdge.target` was an untyped locator: nothing at +// decode could tell a node's child from a file, so the store was built with the file wired into a +// child edge and the fault surfaced one layer later, at the consumer. +// +// A child edge now carries a NodeTarget, whose resolved arm is a SemanticNodeObjectRef, so the +// decoder cannot write the file there at all -- it has to resolve the position, discover an authored +// entry, and refuse. So the cause is `ObjectTableEdgeTargetIsAuthoredSource`, owned by the layer +// reading the document, and the store that would have carried the lie is never built. The claim goes +// red against a decoder that resolves without discriminating (it would report "authored_source" from +// checkout, the old behaviour) and equally red against one that reports "edge_target", the +// not-present cause, for an entry that is right there. +test fn scm_image_a_child_edge_naming_a_file_refuses_at_decode() -> Bool { + scm_image_cause_tag( loaded: decode_closure_document(v: scm_image_program_over_a_file_document()) - ) == "authored_source" + ) == "edge_target_source" +} + +// THE ROOT POSITION IS THE SECOND PLACE A DOCUMENT CAN NAME A FILE WHERE A PROGRAM IS REQUIRED, and +// it has its own cause because it has its own remedy -- a wrong root is not a wrong edge. The +// document is the file alone, with the root naming its position: well-formed by the format's rules, +// and refused for what it says rather than for how it is written. +test fn scm_image_a_root_naming_a_file_refuses_at_decode() -> Bool { + scm_image_cause_tag( + loaded: decode_closure_document( + v: json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array(elements: [scm_image_authored_entry()]) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "0"))]) + ), + ] + ) + ) + ) == "root_source" } // CONTROL, and it is the one that makes the claim above about the FILE rather than about the @@ -1065,23 +1121,69 @@ test fn scm_image_an_entry_carrying_both_shapes_refuses() -> Bool { } // CONTROL, and it is what makes the three claims above about the SOURCE MEMBER rather than about -// this document scaffold: an ordinary authored-source entry loads through the same scaffold. +// this document scaffold: an ordinary authored-source entry loads. +// +// It carries its own scaffold rather than reusing the one above, because that one roots the document +// AT the entry -- which, for a source entry, is now the refused root-names-a-file case. So the file +// sits at position 0 and an empty node at position 1 carries the root: the source entry is decoded +// and stored, and nothing puts it in a position that requires a program. test fn scm_image_an_ordinary_source_entry_loads() -> Bool { - scm_image_entry_cause_tag( - entry: json_object(members: [json_kv(key: "source", value: json_string(s: "text"))]) + scm_image_cause_tag( + loaded: decode_closure_document( + v: json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv( + key: "objects", + value: json_array( + elements: [ + json_object(members: [json_kv(key: "source", value: json_string(s: "text"))]), + scm_image_empty_node_entry(), + ] + ) + ), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) + ) ) == "loaded" } -// THE MERGE CHILD ARM, WITNESSED HERE BECAUSE THIS IS WHERE ITS INPUT CAN BE BUILT. +fn scm_image_empty_node_entry() -> JsonValue { + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv(key: "children", value: json_array(elements: [])), + ] + ) +} + +// THE MERGE ROLE PATH OVER A DOCUMENT, AND WHERE ITS FILE ARM MOVED TO. +// +// This section once carried the `TargetChildIsAuthoredSource` claim, on the reasoning that only a +// DOCUMENT could put a file in a child edge. That reasoning was correct then and is obsolete now: a +// contained edge target is resolved before it is written, so this document no longer loads at all -- +// `scm_image_a_child_edge_naming_a_file_refuses_at_decode` above is what the same input produces. // -// `TargetChildIsAuthoredSource` is unreachable from a proposal: RequireBinding carries a `Node`, so -// every role value a proposal supplies is a semantic node by construction. It is reachable only when -// the TARGET ROOT's own child edge names a file -- which no encoder emits and only the decoder can -// produce, by resolving an edge position into an authored entry. So the same document the checkout -// claim above uses is merged rather than checked out, and the refusal must name the child's KIND. +// The merge arm is still reachable, through the UNRESOLVED edge target, whose kind nothing has +// established until a consumer resolves it. That input is built without a document, from +// insert_from_structure over a store that holds the file, so its claim lives beside merge in +// test.claim.scm.scm_merge_witness -- where the input can be constructed, which is the same rule +// that put it here in the first place. // -// Before this PR's repair, root_edge_for mapped every checkout refusal to TargetChildMissing, so this -// returned "child_missing" for a file that is present. That is the discriminating red. +// What remains here is the ROLE PATH ITSELF: a document whose root has a named child merges, which +// is the control the moved claim needs and the reason this reader stays. fn scm_image_merge_refusal_tag(o: MergeOutcome) -> String { match o { Merged { store: _, commit: _, roles: _ } => "merged" @@ -1170,8 +1272,8 @@ fn scm_image_merge_tag_for(document: JsonValue) -> String { } } -test fn scm_image_a_role_whose_value_is_a_file_refuses_as_authored_source() -> Bool { - scm_image_merge_tag_for(document: scm_image_role_over_a_file_document()) == "child_source" +test fn scm_image_a_role_whose_value_is_a_file_refuses_the_document() -> Bool { + scm_image_merge_tag_for(document: scm_image_role_over_a_file_document()) == "document_refused" } // CONTROL: the identical document with a NODE at position 0 merges, so the refusal above is about diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index 378b911d549..f59bceaea09 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -20,6 +20,10 @@ import gunbc.scm.object_store { StoreOutcome, Stored, LocatorCollision, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, + node_target_locator, CopyOutcome, Copied, CopySourceAbsent, @@ -89,18 +93,33 @@ fn parent_node() -> Node { } // A store holding the parent AND its child, with the parent's identity. -type BuiltStore { store: ObjectStore, root: ObjectId, child: ObjectId } +// THE ROOT IS A NodeTarget AND THE CHILD IS A LOCATOR, which is the two questions this fixture is +// actually asked. The root is handed to CommitClosure, a node-requiring position; the child is only +// ever compared against the identities a completeness fold reports, which are addresses. +type BuiltStore { store: ObjectStore, root: NodeTarget, child: ObjectId } fn build_full_store() -> BuiltStore { match store_node(store: empty_store(), n: child_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => - BuiltStore { store: empty_store(), root: fallback_id(), child: fallback_id() } - Stored { store: s1, identity: child_id } => + BuiltStore { + store: empty_store(), + root: NodeTargetUnresolved(fallback_id()), + child: fallback_id(), + } + Stored { store: s1, node: child_node_ref } => match store_node(store: s1, n: parent_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => - BuiltStore { store: empty_store(), root: fallback_id(), child: fallback_id() } - Stored { store: s2, identity: parent_id } => - BuiltStore { store: s2, root: parent_id, child: child_id } + BuiltStore { + store: empty_store(), + root: NodeTargetUnresolved(fallback_id()), + child: fallback_id(), + } + Stored { store: s2, node: parent_ref } => + BuiltStore { + store: s2, + root: NodeTargetResolved(parent_ref), + child: child_node_ref.locator, + } } } } @@ -109,7 +128,7 @@ fn build_full_store() -> BuiltStore { // structural fact that this value cannot satisfy, so a build failure cannot pass as a green. fn fallback_id() -> ObjectId { match store_node(store: empty_store(), n: child_node()) { - Stored { store: _, identity: i } => i + Stored { store: _, node: n } => n.locator LocatorCollision { identity: i, existing: _, incoming: _ } => i } } @@ -119,8 +138,12 @@ fn fallback_id() -> ObjectId { // completeness. fn build_grafted_store() -> ObjectStore { let full = build_full_store() - match copy_object(from: full.store, to: empty_store(), identity: full.root) { - Copied { store: s, identity: _ } => s + match copy_object( + from: full.store, + to: empty_store(), + identity: node_target_locator(target: full.root), + ) { + Copied { store: s, reference: _ } => s CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } @@ -156,23 +179,33 @@ fn closure_two_child_parent_node() -> Node { ) } -type TwoChildStore { store: ObjectStore, root: ObjectId, first: ObjectId, second: ObjectId } +type TwoChildStore { store: ObjectStore, root: NodeTarget, first: ObjectId, second: ObjectId } fn two_child_fallback() -> TwoChildStore { - TwoChildStore { store: empty_store(), root: fallback_id(), first: fallback_id(), second: fallback_id() } + TwoChildStore { + store: empty_store(), + root: NodeTargetUnresolved(fallback_id()), + first: fallback_id(), + second: fallback_id(), + } } fn build_two_child_store() -> TwoChildStore { match store_node(store: empty_store(), n: closure_alpha_child_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => two_child_fallback() - Stored { store: s1, identity: alpha_id } => + Stored { store: s1, node: alpha_ref } => match store_node(store: s1, n: closure_beta_child_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => two_child_fallback() - Stored { store: s2, identity: beta_id } => + Stored { store: s2, node: beta_ref } => match store_node(store: s2, n: closure_two_child_parent_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => two_child_fallback() - Stored { store: s3, identity: parent_id } => - TwoChildStore { store: s3, root: parent_id, first: alpha_id, second: beta_id } + Stored { store: s3, node: parent_ref } => + TwoChildStore { + store: s3, + root: NodeTargetResolved(parent_ref), + first: alpha_ref.locator, + second: beta_ref.locator, + } } } } @@ -192,8 +225,12 @@ fn first_missing_is(missing: List, expected: ObjectId) -> Bool { test fn two_uncontained_children_are_named_in_order() -> Bool { let built = build_two_child_store() - let grafted = match copy_object(from: built.store, to: empty_store(), identity: built.root) { - Copied { store: s, identity: _ } => s + let grafted = match copy_object( + from: built.store, + to: empty_store(), + identity: node_target_locator(target: built.root), + ) { + Copied { store: s, reference: _ } => s CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } @@ -243,7 +280,7 @@ test fn a_closure_missing_its_own_root_names_the_root() -> Bool { let closure = CommitClosure { store: empty_store(), root: full.root } let missing = unresolved_identities(closure: closure) (list_length(items: missing) == 1) - && names_exactly(missing: missing, expected: full.root) + && names_exactly(missing: missing, expected: node_target_locator(target: full.root)) && !closure_is_complete(closure: closure) } @@ -300,7 +337,7 @@ test fn the_mint_refuses_a_complete_closure() -> Bool { // Mints an admission for the closure under test, or reports the refusal. Every claim below routes // through this, so none of them can accidentally encode a closure the mint never admitted. -fn admitted_for(store: ObjectStore, root: ObjectId) -> PartialClosureAdmissionOutcome { +fn admitted_for(store: ObjectStore, root: NodeTarget) -> PartialClosureAdmissionOutcome { admit_partial_commit_closure( closure: CommitClosure { store: store, root: root }, reason: ClosureTrimmedForTransfer diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index b4f4168d11f..aed0c2c0144 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -14,6 +14,7 @@ import std.types { Bool, Int, List } import v2.std.node { Node, Edge, Named, Conj, Atom, TypeNode, node_synthetic } import gunbc.scm.object_store { ObjectId, ObjectStore, StoreOutcome, Stored, LocatorCollision, + NodeTarget, NodeTargetResolved, NodeTargetUnresolved, node_target_locator, CopyOutcome, Copied, CopySourceAbsent, CopyLocatorCollision, empty_store, copy_object, store_node, } @@ -67,15 +68,17 @@ fn ls_parent() -> Node { ) } -type LsBuilt { store: ObjectStore, root: ObjectId } +type LsBuilt { store: ObjectStore, root: NodeTarget } fn ls_full() -> LsBuilt { match store_node(store: empty_store(), n: ls_child()) { - LocatorCollision { identity: i, existing: _, incoming: _ } => LsBuilt { store: empty_store(), root: i } - Stored { store: s1, identity: _ } => + LocatorCollision { identity: i, existing: _, incoming: _ } => + LsBuilt { store: empty_store(), root: NodeTargetUnresolved(i) } + Stored { store: s1, node: _ } => match store_node(store: s1, n: ls_parent()) { - LocatorCollision { identity: i, existing: _, incoming: _ } => LsBuilt { store: empty_store(), root: i } - Stored { store: s2, identity: p } => LsBuilt { store: s2, root: p } + LocatorCollision { identity: i, existing: _, incoming: _ } => + LsBuilt { store: empty_store(), root: NodeTargetUnresolved(i) } + Stored { store: s2, node: p } => LsBuilt { store: s2, root: NodeTargetResolved(p) } } } } @@ -83,7 +86,7 @@ fn ls_full() -> LsBuilt { fn ls_grafted() -> ObjectStore { let full = ls_full() match copy_object(from: full.store, to: empty_store(), identity: full.root) { - Copied { store: s, identity: _ } => s + Copied { store: s, reference: _ } => s CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } @@ -105,7 +108,7 @@ fn ls_other_generation() -> ObjectId { fn ls_child_identity() -> ObjectId { match store_node(store: empty_store(), n: ls_child()) { - Stored { store: _, identity: i } => i + Stored { store: _, node: n } => n.locator LocatorCollision { identity: i, existing: _, incoming: _ } => i } } @@ -118,7 +121,7 @@ fn ls_permits(standing: LoadStanding) -> Bool { evidence: observe_generation( slot: slot, examined_root: slot.generation, - schema_identity: "gunbc-scm-commit-closure-v2", + schema_identity: "gunbc-scm-commit-closure-v3", standing: standing, authority: decl_ref( module_path: "test.claim.scm_load_standing_witness", diff --git a/dag/test/claim/scm/scm_log_witness_test.dag b/dag/test/claim/scm/scm_log_witness_test.dag index dde7a3b77e0..8e2e46d5af0 100644 --- a/dag/test/claim/scm/scm_log_witness_test.dag +++ b/dag/test/claim/scm/scm_log_witness_test.dag @@ -11,6 +11,9 @@ import gunbc.scm.object_store { store_node, Stored, LocatorCollision, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, } import gunbc.scm.ancestry { RepositoryCommitRef } import gunbc.scm.repository_envelope { @@ -44,15 +47,15 @@ fn log_witness_atom(name: Symbol) -> Node { type LogWitnessStored { store: ObjectStore - identity: ObjectId + identity: NodeTarget } fn log_witness_store_one(store: ObjectStore, name: Symbol) -> LogWitnessStored { match store_node(store: store, n: log_witness_atom(name: name)) { - Stored { store: next, identity: identity } => - LogWitnessStored { store: next, identity: identity } + Stored { store: next, node: node } => + LogWitnessStored { store: next, identity: NodeTargetResolved(node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - LogWitnessStored { store: store, identity: identity } + LogWitnessStored { store: store, identity: NodeTargetUnresolved(identity) } } } @@ -69,7 +72,7 @@ fn log_witness_empty(store: ObjectStore) -> RepositoryEnvelope { } } -fn log_witness_mint(repo: RepositoryEnvelope, root: ObjectId, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn log_witness_mint(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 119a444cf9f..914133eff77 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -19,9 +19,17 @@ import gunbc.scm.object_store { StoreOutcome, Stored, LocatorCollision, + SourceStoreOutcome, + SourceStored, + SourceLocatorCollision, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, empty_store, store_node, store_authored_source, + insert_from_structure, + StoredEdge, object_id_eq, } @@ -79,18 +87,45 @@ type Fixture { fn store_or_empty(outcome: StoreOutcome) -> ObjectStore { match outcome { - Stored { store: store, identity: _ } => store + Stored { store: store, node: _ } => store LocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } } +fn root_or(outcome: StoreOutcome, fallback: ObjectId) -> NodeTarget { + match outcome { + Stored { store: _, node: node } => NodeTargetResolved(node) + LocatorCollision { identity: _, existing: _, incoming: _ } => NodeTargetUnresolved(fallback) + } +} + fn identity_or(outcome: StoreOutcome, fallback: ObjectId) -> ObjectId { match outcome { - Stored { store: _, identity: identity } => identity + Stored { store: _, node: node } => node.locator LocatorCollision { identity: _, existing: _, incoming: _ } => fallback } } +// THE SOURCE FIXTURE'S ROOT IS AN UNRESOLVED TARGET, AND THAT IS THE ONLY SHAPE IT COULD HAVE. +// store_authored_source hands back an AuthoredSourceObjectRef, which no node-requiring position +// accepts, so the file's LOCATOR is what reaches the commit -- a bare address whose kind nothing has +// established. That is exactly the state merge must resolve and refuse on, and it is still fully +// authorable, which is why this claim survives the reference types rather than being deleted by +// them. +fn source_store_or_empty(outcome: SourceStoreOutcome) -> ObjectStore { + match outcome { + SourceStored { store: store, source: _ } => store + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() + } +} + +fn source_root_or(outcome: SourceStoreOutcome, fallback: ObjectId) -> NodeTarget { + match outcome { + SourceStored { store: _, source: source } => NodeTargetUnresolved(source.locator) + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => NodeTargetUnresolved(fallback) + } +} + // THE TARGET IS A COMMIT THAT ALREADY EXISTS, and every arm below starts from this same one. Holding // the target and the authored operations FIXED across the pair is what makes the pair a // discriminator: if the arms differed in more than the dependency edge, an implementation could @@ -99,13 +134,13 @@ fn fixture_with(edges: List) -> Fixture { let outcome = store_node(store: empty_store(), n: scope_of(edges: edges)) Fixture { store: store_or_empty(outcome: outcome), - target: Commit { root: identity_or(outcome: outcome, fallback: fallback_id()) }, + target: Commit { root: root_or(outcome: outcome, fallback: fallback_id()) }, } } fn fallback_id() -> ObjectId { match store_node(store: empty_store(), n: merge_witness_leaf(name: ^fallback)) { - Stored { store: _, identity: identity } => identity + Stored { store: _, node: node } => node.locator LocatorCollision { identity: identity, existing: _, incoming: _ } => identity } } @@ -621,15 +656,15 @@ test fn an_empty_proposal_set_preserves_the_target() -> Bool { // // Each claim is paired with a control that substitutes a NODE at the same position, so the refusal is // attributable to the object's KIND rather than to the fixture being unmergeable. -fn merge_witness_source_store() -> StoreOutcome { +fn merge_witness_source_store() -> SourceStoreOutcome { store_authored_source(store: empty_store(), text: "not a program\n") } fn merge_witness_source_fixture() -> Fixture { let outcome = merge_witness_source_store() Fixture { - store: store_or_empty(outcome: outcome), - target: Commit { root: identity_or(outcome: outcome, fallback: fallback_id()) }, + store: source_store_or_empty(outcome: outcome), + target: Commit { root: source_root_or(outcome: outcome, fallback: fallback_id()) }, } } @@ -676,3 +711,75 @@ test fn merge_a_target_root_that_is_a_node_does_not_refuse() -> Bool { o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) ) == "merged" } + +// THE CHILD ARM, WITNESSED WHERE ITS INPUT CAN NOW BE BUILT. +// +// This claim used to live beside the closure-document builder, because a document was the only thing +// that could put a file into a child edge. It is not any more: a contained edge target is resolved +// before the decoder writes it, so that document refuses at decode and never reaches merge. +// +// The arm is still reachable, and this is the shape that reaches it. A child edge may carry an +// UNRESOLVED target -- a bare locator whose kind nothing has established, which is what a partial +// closure's uncontained reference decodes to -- and the store here holds a FILE at that locator. So +// merge resolves the role value and must refuse naming the child's KIND. Against the wildcard this +// PR removed, which mapped every checkout refusal to TargetChildMissing, it reports "child_missing" +// for an object that is present: that is the discriminating red. +// +// The record is built through insert_from_structure rather than by hand, because ObjectRecord is +// sole_constructor -- the fixture chooses the STRUCTURE and the store derives the identity, so this +// target is incomplete-by-construction and never dishonest. +fn merge_witness_role_over_a_file_fixture() -> Fixture { + match store_authored_source(store: empty_store(), text: "not a program\n") { + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => + Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(fallback_id()) } } + SourceStored { store: with_file, source: file } => + match insert_from_structure( + store: with_file, + kind: TypeNode { connective: Conj }, + children: [ + StoredEdge { label: Named { name: ^s }, target: NodeTargetUnresolved(file.locator) }, + ], + ) { + LocatorCollision { identity: i, existing: _, incoming: _ } => + Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Stored { store: assembled, node: root } => + Fixture { store: assembled, target: Commit { root: NodeTargetResolved(root) } } + } + } +} + +test fn merge_a_role_whose_value_is_a_file_refuses_as_authored_source() -> Bool { + let fx = merge_witness_role_over_a_file_fixture() + merge_refusal_tag( + o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) + ) == "child_source" +} + +// CONTROL, one fact apart: the same assembly with a NODE under the child edge merges. Without it a +// merge that refused every hand-assembled target would satisfy the claim above. +fn merge_witness_role_over_a_node_fixture() -> Fixture { + match store_node(store: empty_store(), n: merge_witness_leaf(name: ^child)) { + LocatorCollision { identity: i, existing: _, incoming: _ } => + Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Stored { store: with_child, node: child } => + match insert_from_structure( + store: with_child, + kind: TypeNode { connective: Conj }, + children: [ + StoredEdge { label: Named { name: ^s }, target: NodeTargetUnresolved(child.locator) }, + ], + ) { + LocatorCollision { identity: i, existing: _, incoming: _ } => + Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Stored { store: assembled, node: root } => + Fixture { store: assembled, target: Commit { root: NodeTargetResolved(root) } } + } + } +} + +test fn merge_a_role_whose_value_is_a_node_merges() -> Bool { + let fx = merge_witness_role_over_a_node_fixture() + merge_refusal_tag( + o: merge(store: fx.store, target: fx.target, target_dependencies: [], proposals: []) + ) == "merged" +} diff --git a/dag/test/claim/scm/scm_object_store_witness_test.dag b/dag/test/claim/scm/scm_object_store_witness_test.dag index 6f6795610d8..40e9aa0e713 100644 --- a/dag/test/claim/scm/scm_object_store_witness_test.dag +++ b/dag/test/claim/scm/scm_object_store_witness_test.dag @@ -46,6 +46,10 @@ import gunbc.scm.object_store { ObjectId, ObjectRecord, StoredEdge, + NodeTarget, + NodeTargetResolved, + NodeTargetUnresolved, + node_target_locator, ObjectStore, NodeRecordFound, NodeRecordAbsent, @@ -164,17 +168,25 @@ fn structurally_same(left: Node, right: Node) -> Bool { // 64-bit value. This carrier keeps the claims readable while making a collision VISIBLE rather than // absorbed: `collided` is asserted false across the whole fixture set by its own claim below, so a // fixture that started colliding would surface instead of quietly reshaping every other claim. +// THE FIXTURE CARRIES A NodeTarget, AND THE COLLISION ARM IS WHY IT IS NOT THE BRANDED REFERENCE. +// +// A SemanticNodeObjectRef is `sole_constructor` -- only the store mints one -- so this arm, which +// has no stored node, has nothing to put there and no way to fabricate one. That refusal is the +// wall working: the fixture cannot assert a node reference it never obtained. NodeTarget is the +// type that CAN say what is true in both arms -- resolved to a node, or a bare locator whose kind +// nothing established -- and it is exactly what commit_of_root takes. type Put { store: ObjectStore - identity: ObjectId + identity: NodeTarget collided: Bool } fn put_node(store: ObjectStore, n: Node) -> Put { match store_node(store: store, n: n) { - Stored { store: s, identity: i } => Put { store: s, identity: i, collided: false } + Stored { store: s, node: i } => + Put { store: s, identity: NodeTargetResolved(i), collided: false } LocatorCollision { identity: i, existing: _, incoming: _ } => - Put { store: empty_store(), identity: i, collided: true } + Put { store: empty_store(), identity: NodeTargetUnresolved(i), collided: true } } } @@ -215,7 +227,7 @@ fn checkout_refuses(store: ObjectStore, commit: Commit) -> Bool { // fixture can be incomplete but cannot be dishonest. fn store_of_root_only(full: ObjectStore, root: ObjectId) -> ObjectStore { match copy_object(from: full, to: empty_store(), identity: root) { - Copied { store: store, identity: _ } => store + Copied { store: store, reference: _ } => store CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } @@ -265,7 +277,10 @@ test fn absent_root_refuses() -> Bool { // has to come from the recursive descent, not from a guard on the entry point. test fn missing_reachable_child_refuses() -> Bool { let stored = put_node(store: empty_store(), n: program_ab()) - let severed = store_of_root_only(full: stored.store, root: stored.identity) + let severed = store_of_root_only( + full: stored.store, + root: node_target_locator(target: stored.identity), + ) checkout_refuses( store: severed, commit: commit_of_root(root: stored.identity), @@ -276,20 +291,29 @@ test fn missing_reachable_child_refuses() -> Bool { // what distinguishes a located refusal from a refusal that merely echoes what was asked for. test fn the_refusal_names_the_missing_child_not_the_root() -> Bool { let stored = put_node(store: empty_store(), n: program_ab()) - let severed = store_of_root_only(full: stored.store, root: stored.identity) + let severed = store_of_root_only( + full: stored.store, + root: node_target_locator(target: stored.identity), + ) let left_child = put_node(store: empty_store(), n: atom_node(identity: ^L)) refusal_names( store: severed, commit: commit_of_root(root: stored.identity), - expected: left_child.identity, - ) && !object_id_eq(left: left_child.identity, right: stored.identity) + expected: node_target_locator(target: left_child.identity), + ) && !object_id_eq( + left: node_target_locator(target: left_child.identity), + right: node_target_locator(target: stored.identity), + ) } // CONTROL for the case above: the severed store is not merely empty. If it were, the claim would be // a restatement of absent_root_refuses rather than a statement about a missing child. test fn severed_store_still_holds_its_root() -> Bool { let stored = put_node(store: empty_store(), n: program_ab()) - let severed = store_of_root_only(full: stored.store, root: stored.identity) + let severed = store_of_root_only( + full: stored.store, + root: node_target_locator(target: stored.identity), + ) store_object_count(store: severed) == 1 } @@ -358,14 +382,14 @@ fn same_record(left: ObjectStore, right: ObjectStore, identity: ObjectId) -> Boo test fn both_insertion_orders_produce_the_same_canonical_record() -> Bool { let a_then_b = put_node(store: put_node(store: empty_store(), n: alpha_first()).store, n: beta_first()) let b_then_a = put_node(store: put_node(store: empty_store(), n: beta_first()).store, n: alpha_first()) - same_record(left: a_then_b.store, right: b_then_a.store, identity: a_then_b.identity) + same_record(left: a_then_b.store, right: b_then_a.store, identity: node_target_locator(target: a_then_b.identity)) } // CONTROL: same_record is not vacuously true -- an absent record does not read as equal to a // present one. test fn the_record_comparison_discriminates() -> Bool { let stored = put_node(store: empty_store(), n: alpha_first()) - !same_record(left: stored.store, right: empty_store(), identity: stored.identity) + !same_record(left: stored.store, right: empty_store(), identity: node_target_locator(target: stored.identity)) } // The store holds ONE record for that one identity, whichever order arrived first. @@ -406,7 +430,7 @@ test fn the_two_orders_are_different_as_authored() -> Bool { test fn independently_built_equal_programs_share_one_identity() -> Bool { let one = put_node(store: empty_store(), n: program_ab()) let two = put_node(store: empty_store(), n: program_ab()) - object_id_eq(left: one.identity, right: two.identity) + object_id_eq(left: node_target_locator(target: one.identity), right: node_target_locator(target: two.identity)) } // CRITERION 3, second half -- and the gap the first half leaves open. If every node carried the same @@ -417,8 +441,8 @@ test fn identity_survives_reminted_occurrences() -> Bool { let synthetic = put_node(store: empty_store(), n: program_ab()) let minted_low = put_node(store: empty_store(), n: minted_program_ab(base: 10)) let minted_high = put_node(store: empty_store(), n: minted_program_ab(base: 900)) - object_id_eq(left: synthetic.identity, right: minted_low.identity) - && object_id_eq(left: minted_low.identity, right: minted_high.identity) + object_id_eq(left: node_target_locator(target: synthetic.identity), right: node_target_locator(target: minted_low.identity)) + && object_id_eq(left: node_target_locator(target: minted_low.identity), right: node_target_locator(target: minted_high.identity)) } // CONTROL for the remint claim: the two bases really do allocate different occurrence ids, so the @@ -431,7 +455,7 @@ test fn reminted_programs_carry_different_occurrences() -> Bool { test fn different_programs_get_different_identities() -> Bool { let one = put_node(store: empty_store(), n: program_a()) let two = put_node(store: empty_store(), n: program_ab()) - !object_id_eq(left: one.identity, right: two.identity) + !object_id_eq(left: node_target_locator(target: one.identity), right: node_target_locator(target: two.identity)) } // Storing the same program twice adds nothing: content addressing makes writes idempotent. @@ -493,9 +517,14 @@ test fn the_oracle_distinguishes_node_kinds() -> Bool { // fixture takes. test fn grafting_an_absent_object_refuses() -> Bool { let stored = put_node(store: empty_store(), n: program_ab()) - match copy_object(from: empty_store(), to: empty_store(), identity: stored.identity) { - CopySourceAbsent { identity: identity } => object_id_eq(left: identity, right: stored.identity) - Copied { store: _, identity: _ } => false + match copy_object( + from: empty_store(), + to: empty_store(), + identity: node_target_locator(target: stored.identity), + ) { + CopySourceAbsent { identity: identity } => + object_id_eq(left: identity, right: node_target_locator(target: stored.identity)) + Copied { store: _, reference: _ } => false CopyLocatorCollision { identity: _, existing: _, incoming: _ } => false } } @@ -504,8 +533,13 @@ test fn grafting_an_absent_object_refuses() -> Bool { // than about copy_object refusing unconditionally. test fn grafting_a_present_object_copies_it() -> Bool { let stored = put_node(store: empty_store(), n: program_ab()) - match copy_object(from: stored.store, to: empty_store(), identity: stored.identity) { - Copied { store: store, identity: _ } => store_contains(store: store, identity: stored.identity) + match copy_object( + from: stored.store, + to: empty_store(), + identity: node_target_locator(target: stored.identity), + ) { + Copied { store: store, reference: _ } => + store_contains(store: store, identity: node_target_locator(target: stored.identity)) CopySourceAbsent { identity: _ } => false CopyLocatorCollision { identity: _, existing: _, incoming: _ } => false } @@ -574,7 +608,10 @@ test fn the_recursive_oracle_still_distinguishes_nested_content() -> Bool { // Within its boundary it is discriminating by execution: reversing the child fold in the STORE path // only -- so the two paths genuinely diverge -- turns it red (DIVERGE_PROBE 1, "claim red"). fn stored_identity_matches_content_hash(n: Node) -> Bool { - object_id_eq(left: put_node(store: empty_store(), n: n).identity, right: content_hash(n: n)) + object_id_eq( + left: node_target_locator(target: put_node(store: empty_store(), n: n).identity), + right: content_hash(n: n), + ) } test fn store_node_identity_commutes_with_content_hash() -> Bool { diff --git a/dag/test/claim/scm/scm_render_witness_test.dag b/dag/test/claim/scm/scm_render_witness_test.dag index 3ebeafb1eaf..cad0c3b3c46 100644 --- a/dag/test/claim/scm/scm_render_witness_test.dag +++ b/dag/test/claim/scm/scm_render_witness_test.dag @@ -18,7 +18,9 @@ import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.render { Document, RenderCapability } import std.symbols { Ascii } import extdeps.render.surface { serialize_document, PlainTerminal, GithubActions } -import gunbc.scm.object_store { empty_store, store_node, Stored, LocatorCollision, ObjectId, ObjectStore } +import gunbc.scm.object_store { + empty_store, store_node, Stored, LocatorCollision, ObjectId, ObjectStore, +} import v2.std.node { Node, Symbol, TypeNode, Atom, node_synthetic } import extdeps.render.ansi { ansi_escape } import gunbc.scm.repository_envelope { @@ -33,6 +35,7 @@ import gunbc.scm.status { RepositoryStatus, StatusCheckout, CheckedOutCommit, + NothingCheckedOut, } import gunbc.scm.log { repository_log, CommitLog } import extdeps.languages.json.parse { JsonDocumentGap, JsonDocumentMalformedAt } @@ -63,10 +66,21 @@ fn render_witness_atom(name: Symbol) -> Node { node_synthetic(kind: TypeNode { connective: Atom { identity: name } }, children: []) } -fn render_witness_object_id() -> ObjectId { +// THE FIXTURE CANNOT FABRICATE A ROOT REFERENCE, so the collision arm answers with the whole +// STATUS rather than with a reference it does not have. `CheckedOutCommit.root` is a +// SemanticNodeObjectRef and only the store mints one; a fixture that could invent one would be the +// forge the seal exists to prevent. The arm is unreachable -- the store is empty -- and it now says +// so by producing the no-checkout status instead of a plausible-looking identity. +fn render_witness_checked_out(message: String) -> RepositoryStatus { match store_node(store: empty_store(), n: render_witness_atom(name: ^r1)) { - Stored { store: _, identity: identity } => identity - LocatorCollision { identity: identity, existing: _, incoming: _ } => identity + Stored { store: _, node: node } => + RepositoryStatus { + checkout: CheckedOutCommit { root: node, message: message }, + commit_count: 1, + staged: [], + } + LocatorCollision { identity: _, existing: _, incoming: _ } => + RepositoryStatus { checkout: NothingCheckedOut, commit_count: 1, staged: [] } } } @@ -263,14 +277,6 @@ test fn bytes_and_exit_status_are_independent_facts() -> Bool { // success" is now UNWRITABLE rather than false, so a fixture asserting it could not be authored and // the check would be permanently green BY CONSTRUCTION -- the decoration section 4b names as worse // than absent. What is asserted is the reachable behaviour a caller can still get wrong. -fn render_witness_checked_out(message: String) -> RepositoryStatus { - RepositoryStatus { - checkout: CheckedOutCommit { root: render_witness_object_id(), message: message }, - commit_count: 1, - staged: [], - } -} - fn control_byte_response(message: String) -> CliWireResponse { scm_status_cli_response( result: ScmReadAnswered { path: "repo.json", answer: render_witness_checked_out(message: message) }, diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index f9ea8ce6c92..669b4812b4b 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -28,6 +28,8 @@ import v2.std.node { import gunbc.scm.object_store { ObjectId, ObjectStore, StoreOutcome, Stored, LocatorCollision, + SourceStored, SourceLocatorCollision, + NodeTarget, NodeTargetResolved, NodeTargetUnresolved, node_target_locator, empty_store, object_id_eq, store_node, store_object_count, CopyOutcome, Copied, CopySourceAbsent, CopyLocatorCollision, copy_object, store_authored_source, @@ -50,6 +52,7 @@ import gunbc.scm.repository_envelope { RepositoryIdentityRuleUnrecognized, RepositoryTargetReferenceRefused, RepositoryCommitRootReferenceUnresolved, RepositoryCheckoutReferenceUnresolved, + RepositoryCommitRootNotInObjectTable, RepositoryCommitRootIsAuthoredSource, RepositoryUnexpectedMember, RepositoryMemberContext, RepositoryEnvelopeMembers, RepositoryCommitMembers, encode_repository_checked, @@ -122,12 +125,13 @@ fn scm_env_program_two() -> Node { ) } -type ScmEnvPut { store: ObjectStore, identity: ObjectId } +type ScmEnvPut { store: ObjectStore, identity: NodeTarget } fn scm_env_put(store: ObjectStore, n: Node) -> ScmEnvPut { match store_node(store: store, n: n) { - Stored { store: s, identity: i } => ScmEnvPut { store: s, identity: i } - LocatorCollision { identity: i, existing: _, incoming: _ } => ScmEnvPut { store: store, identity: i } + Stored { store: s, node: n } => ScmEnvPut { store: s, identity: NodeTargetResolved(n) } + LocatorCollision { identity: i, existing: _, incoming: _ } => + ScmEnvPut { store: store, identity: NodeTargetUnresolved(i) } } } @@ -144,7 +148,7 @@ fn scm_env_repository_with_store(store: ObjectStore) -> RepositoryEnvelope { } } -fn scm_env_minted_or(repo: RepositoryEnvelope, root: ObjectId, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn scm_env_minted_or(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo @@ -229,7 +233,7 @@ test fn scm_env_the_commit_counter_distinguishes_a_populated_repository() -> Boo // the roots into one string in order is how order is compared at all. A membership check would have // passed for a decoder that reversed the log. fn scm_env_root_signature(commits: List) -> String { - fold(commits, init: "", f: fn(acc, c) { concat(acc, concat("|", c.root.digest as String)) }) + fold(commits, init: "", f: fn(acc, c) { concat(acc, concat("|", c.root.locator.digest as String)) }) } test fn scm_env_commit_identities_survive_reconstruction() -> Bool { @@ -535,7 +539,10 @@ fn scm_env_absent_root_repository() -> RepositoryEnvelope { v: c, key: "root", value: json_object(members: [ - json_kv(key: "uncontained", value: json_string(s: orphan.identity.digest as String)), + json_kv( + key: "uncontained", + value: json_string(s: node_target_locator(target: orphan.identity).digest as String), + ), ]), ), ) @@ -576,8 +583,12 @@ fn scm_env_non_commit_checkout_repository() -> RepositoryEnvelope { // one nobody can resolve back, and a tag-only claim could not tell the two apart. test fn scm_env_an_uncontained_target_refuses_to_encode_and_names_it() -> Bool { let full = scm_env_put(store: empty_store(), n: scm_env_program_two()) - let grafted = match copy_object(from: full.store, to: empty_store(), identity: full.identity) { - Copied { store: s, identity: _ } => s + let grafted = match copy_object( + from: full.store, + to: empty_store(), + identity: node_target_locator(target: full.identity), + ) { + Copied { store: s, reference: _ } => s CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() } @@ -658,6 +669,8 @@ fn scm_env_decode_cause_tag(outcome: RepositoryDecodeOutcome) -> String { RepositoryFormatUnrecognized { found: _ } => "format" RepositoryIdentityRuleUnrecognized { found: _ } => "identity_rule" RepositoryCommitRootReferenceUnresolved { reference: _ } => "commit_root" + RepositoryCommitRootNotInObjectTable { root: _ } => "commit_root_absent" + RepositoryCommitRootIsAuthoredSource { root: _ } => "commit_root_source" RepositoryCheckoutReferenceUnresolved { reference: _ } => "checked_out" RepositoryCommitReferenceDuplicatedOnWire { reference: _ } => "commit_duplicate" RepositoryCommitParentNotPreviouslyAdmitted { parent: _ } => "commit_parent" @@ -1142,15 +1155,16 @@ test fn scm_env_reordering_rows_does_not_change_which_commit_a_key_names() -> Bo // back in the refusal, so a refusal naming some other locator does not satisfy it. test fn env_a_commit_root_that_is_a_source_file_refuses_as_authored_source() -> Bool { match store_authored_source(store: empty_store(), text: "not a program") { - LocatorCollision { identity: _, existing: _, incoming: _ } => false - Stored { store: store, identity: file } => + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => false + SourceStored { store: store, source: file } => match mint_repository_commit( repository: scm_env_repository_with_store(store: store), - root: file, + root: NodeTargetUnresolved(file.locator), message: "root is a file", parent: none, ) { - RepositoryCommitMintRootIsAuthoredSource { root: r } => object_id_eq(left: r, right: file) + RepositoryCommitMintRootIsAuthoredSource { root: r } => + object_id_eq(left: r, right: file.locator) RepositoryCommitMinted { repository: _, reference: _ } => false RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => false RepositoryCommitMintParentMissing { parent: _ } => false diff --git a/dag/test/claim/scm/scm_status_witness_test.dag b/dag/test/claim/scm/scm_status_witness_test.dag index c854e164a6d..7fa43576605 100644 --- a/dag/test/claim/scm/scm_status_witness_test.dag +++ b/dag/test/claim/scm/scm_status_witness_test.dag @@ -4,7 +4,10 @@ import std.types { Bool, String, List, Int } import std.minted_identity { MintedId, MintedIdAllocator } import v2.std.node { Node, Symbol, TypeNode, Atom, node_synthetic } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } -import gunbc.scm.object_store { ObjectStore, ObjectId, empty_store, store_node, Stored, LocatorCollision } +import gunbc.scm.object_store { + ObjectStore, ObjectId, empty_store, store_node, Stored, LocatorCollision, + NodeTarget, NodeTargetResolved, NodeTargetUnresolved, +} import gunbc.scm.ancestry { RepositoryCommitRef } import gunbc.scm.repository_envelope { RepositoryEnvelope, @@ -39,13 +42,14 @@ fn status_witness_atom(name: Symbol) -> Node { node_synthetic(kind: TypeNode { connective: Atom { identity: name } }, children: []) } -type StatusWitnessStored { store: ObjectStore, identity: ObjectId } +type StatusWitnessStored { store: ObjectStore, identity: NodeTarget } fn status_witness_stored(name: Symbol) -> StatusWitnessStored { match store_node(store: empty_store(), n: status_witness_atom(name: name)) { - Stored { store: store, identity: identity } => StatusWitnessStored { store: store, identity: identity } + Stored { store: store, node: node } => + StatusWitnessStored { store: store, identity: NodeTargetResolved(node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - StatusWitnessStored { store: empty_store(), identity: identity } + StatusWitnessStored { store: empty_store(), identity: NodeTargetUnresolved(identity) } } } diff --git a/dag/test/claim/scm/scm_supersession_witness_test.dag b/dag/test/claim/scm/scm_supersession_witness_test.dag index e7ed745d2fd..04ae1aaf5a0 100644 --- a/dag/test/claim/scm/scm_supersession_witness_test.dag +++ b/dag/test/claim/scm/scm_supersession_witness_test.dag @@ -48,7 +48,7 @@ fn sup_node(tag: Symbol) -> Node { fn sup_identity(tag: Symbol) -> ObjectId { match store_node(store: empty_store(), n: sup_node(tag: tag)) { - Stored { store: _, identity: i } => i + Stored { store: _, node: n } => n.locator LocatorCollision { identity: i, existing: _, incoming: _ } => i } } @@ -78,7 +78,7 @@ fn sup_evidence(slot: HeadSlot, examined_root: ObjectId, standing: LoadStanding) observe_generation( slot: slot, examined_root: examined_root, - schema_identity: "gunbc-scm-commit-closure-v2", + schema_identity: "gunbc-scm-commit-closure-v3", standing: standing, authority: sup_authority(), ) From 488b09d128695aef0fa503bf76914eab6e7514da Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 11:45:06 +0000 Subject: [PATCH 07/25] Compare records by what the identity is derived from, and count the durable documents before claiming there are none Four repairs, each found by executing rather than by reading. RECORDS_EQUAL WAS COMPARING THE TARGET'S ARM. It was `left.children == right.children` -- the substrate's structural equality -- which was exactly right while a StoredEdge held a bare locator, and stopped being right the moment the target became a NodeTarget. `NodeTargetResolved(r)` and `NodeTargetUnresolved(l)` over the SAME locator are different values structurally and the same object by content, because content_hash_of_children consumes only the locator. So the store reported "different record" for two records its own identity rule cannot tell apart, and insert_admission turned that into a LocatorCollision: a FABRICATED refusal, raised at a store being asked to do something ordinary. merge reaches it directly -- it reassembles a root from resolved children and re-inserts over a record whose children were unresolved. It was caught by a CONTROL, not by review. The file arm of the merge role claim passed and its node control failed, which is the whole reason a claim is paired with one. The comparison is now over label and LOCATOR, through the same labeled_children_of_stored the identity fold consumes, so there is one definition of what makes two records the same rather than two that agree until one is edited. THE DURABLE POPULATION WAS ASSUMED EMPTY AND IS NOT. The tag-bump note said no document exists to be compatible with, because the only producer is a witness rebuilt every run. True of the closure format; FALSE of the repository envelope, which has checked-in fixtures under dag/test/fixture/scm_repository_load carrying the v1 tags -- six claims went red on them. They are versioned test inputs, updated with the schema they exercise, and the note now says the population was counted rather than assumed. AN ENCODE FIXTURE BUILT ITS SUBJECT THROUGH A ROUTE THAT IS NOW CLOSED. scm_env_absent_root_repository rewrote an encoded root to an uncontained digest and decoded it back; decode now resolves a commit root against the object table and refuses. The state is still reachable and the fixture now reaches it honestly: mint against a store that HOLDS the root, then rebuild the envelope around a store that does not -- which is exactly the residual a SemanticNodeObjectRef leaves open, since it says its object was a node in SOME store and never in THIS one. The document route became its own decode-refusal claim, with a control one fact apart. A DUPLICATE IMPORT, named by review 58166 and real: ObjectTableEdgeTargetIsAuthoredSource was bound twice in one block, which I introduced by inserting it in two separate edits. Deleted. The review's other half -- that ingest may refuse on it -- is not the case: this module resolved cleanly throughout, and three duplicate-import blocks already stand on main from #9763, #9850 and #9669. The DESIGN section 2 objection is the one that holds. Instrument: claim_batch over every dag/test/claim/scm/*_witness_test.dag with each file's own `test fn` roster. 299 pass, 0 fail, exit 0 -- including all eight claims this branch added or moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure_json_v2.dag | 13 +++-- dag/gunbc/scm/object_store.dag | 22 +++++++- .../scm/scm_load_standing_witness_test.dag | 10 +++- .../scm_repository_envelope_witness_test.dag | 56 +++++++++++++++++-- .../scm_repository_load/empty_repository.json | 4 +- 5 files changed, 88 insertions(+), 17 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 0401cf25d16..fee251a1e9f 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -17,7 +17,6 @@ import gunbc.scm.object_table_json { ObjectTableUnknownLabelTag, ObjectTableEdgeTargetUnresolved, ObjectTableEdgeTargetIsAuthoredSource, - ObjectTableEdgeTargetIsAuthoredSource, ObjectTableObjectCollision, object_table_load_standing, } @@ -248,11 +247,13 @@ type ClosureDocLoadOutcome // DESIGN section 3 -- one name, two materially different contracts -- and it would have been THIS // module's policy broken by this module. Named by external review of gunbc#9891. // -// ONE TAG, NOT TWO, AND THE REASON IS THAT NO v2 DOCUMENT EXISTS TO BE COMPATIBLE WITH. A decoder -// that supported both would say so by admitting both tags; there is nothing to admit. The only -// producer of these bytes is a witness rebuilt from source every run, so there is no durable -// artifact whose v2 spelling has to keep loading, and inventing a compatibility arm for an empty -// population would be machinery with no consumer. +// ONE TAG, NOT TWO, AND THE POPULATION WAS COUNTED RATHER THAN ASSUMED EMPTY. A decoder supporting +// both versions would say so by admitting both tags. The closure document has no durable instances: +// its only producer is a witness rebuilt from source every run. The REPOSITORY envelope beside it +// does have some -- checked-in fixtures under dag/test/fixture/scm_repository_load -- and those are +// versioned test inputs updated with the schema they exercise, not data whose old spelling anyone +// is owed. Neither is a live population that a compatibility arm would serve, and an arm with no +// consumer is machinery this repository refuses to carry. data closure_document_format_tag: String = "gunbc-scm-commit-closure-v3" // WHAT THIS MODULE IS AND IS NOT, so the next layer is not built on a wrong assumption. diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index 606e7b554a3..618bab2d24a 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -449,8 +449,28 @@ fn insert_admission(store: ObjectStore, object: ScmObject) -> InsertAdmission { // Record equality is the substrate's own structural equality over both fields. StoredEdge is a // label and a derived child identity, so comparing the child LISTS compares the whole subtree by // its already-derived identities -- no re-walk, no digest projection, no hand-written pairwise fold. +// EQUALITY IS OVER WHAT THE IDENTITY IS DERIVED FROM, AND THE TARGET'S ARM IS NOT PART OF THAT. +// +// This was `left.children == right.children`, the substrate's structural equality, which was exactly +// right while a StoredEdge held a bare locator. It stopped being right when the target became a +// NodeTarget: `NodeTargetResolved(r)` and `NodeTargetUnresolved(l)` over the SAME locator are +// different values structurally and the same object by content, because content_hash_of_children +// consumes only the locator. Structural equality therefore reported "different record" for two +// records the identity rule cannot distinguish, and insert_admission turned that into a +// LocatorCollision -- a fabricated refusal, for a store that was being asked to do something +// perfectly ordinary. +// +// Found by a control, not by review: the file arm of the merge role claim passed and its NODE +// control failed, because merge reassembles the root from resolved children and re-inserts it over a +// record whose children were unresolved. +// +// So the comparison is over the label and the LOCATOR -- the same two things +// labeled_children_of_stored hands to the identity fold, which is what keeps this from being a +// second, drifting definition of what makes two records the same. fn records_equal(left: ObjectRecord, right: ObjectRecord) -> Bool { - (left.kind == right.kind) && (left.children == right.children) + (left.kind == right.kind) + && (labeled_children_of_stored(children: left.children) + == labeled_children_of_stored(children: right.children)) } // CROSS-KIND IS A COLLISION, NOT AN EQUALITY. A node and a file under one locator are two different diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index aed0c2c0144..dff1d6292e8 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -85,7 +85,11 @@ fn ls_full() -> LsBuilt { fn ls_grafted() -> ObjectStore { let full = ls_full() - match copy_object(from: full.store, to: empty_store(), identity: full.root) { + match copy_object( + from: full.store, + to: empty_store(), + identity: node_target_locator(target: full.root), + ) { Copied { store: s, reference: _ } => s CopySourceAbsent { identity: _ } => empty_store() CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() @@ -99,7 +103,7 @@ fn ls_grafted() -> ObjectStore { // left is the standing, which is what the three tests below vary. A helper that also varied the // generation would report false for the wrong reason (DESIGN: a control that does not flip). fn ls_slot() -> HeadSlot { - HeadSlot { slot: "head", generation: ls_full().root } + HeadSlot { slot: "head", generation: node_target_locator(target: ls_full().root) } } fn ls_other_generation() -> ObjectId { @@ -215,7 +219,7 @@ test fn an_unresolved_position_is_malformed_not_fetchable() -> Bool { test fn an_honest_collision_is_its_own_standing_and_never_supersedes() -> Bool { let collision = closure_document_load_standing( cause: ClosureDocObjectTableRefused { - cause: ObjectTableObjectCollision { identity: ls_full().root } + cause: ObjectTableObjectCollision { identity: node_target_locator(target: ls_full().root) } } ) let malformed = closure_document_load_standing( diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 669b4812b4b..ef7e4dadb21 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -524,7 +524,40 @@ fn scm_env_encode_cause_tag(outcome: RepositoryEncodeOutcome) -> String { } // A commit whose root is not in the store at all. +// A COMMIT WHOSE ROOT THE STORE DOES NOT HOLD, BUILT BY SWAPPING THE STORE RATHER THAN THE DOCUMENT. +// +// It used to be built by rewriting the encoded root to an uncontained digest and decoding that back. +// That route is closed: decode now RESOLVES a commit root against the object table and refuses when +// the table does not carry it, so the rewritten document no longer produces a repository at all -- +// it produces a refusal, which is asserted on its own below. +// +// The state is still reachable, and this is what reaches it. A commit is minted against a store that +// DOES hold its root, so the reference is honestly branded; the envelope is then rebuilt around a +// different store that does not hold it. A SemanticNodeObjectRef says its object was a node in SOME +// store, never in THIS one, so this is exactly the residual the brand leaves open and exactly what +// the encode-side check is for. fn scm_env_absent_root_repository() -> RepositoryEnvelope { + let orphan = scm_env_put(store: empty_store(), n: scm_env_program_two()) + let held = scm_env_put(store: empty_store(), n: scm_env_program_one()) + let minted = scm_env_minted_or( + repo: scm_env_repository_with_store(store: orphan.store), + root: orphan.identity, + message: "dangling", + parent: none, + ) + RepositoryEnvelope { + store: held.store, + commits: minted.commits, + commit_allocator: minted.commit_allocator, + checked_out: none, + } +} + +// THE DOCUMENT ROUTE, NOW A DECODE REFUSAL AND CARRYING ITS OWN CAUSE. An envelope whose commit root +// names an object the table does not carry is refused when it is read, not admitted and noticed at +// the next write -- and "the table does not carry it" is a different cause, with a different remedy, +// from "the table carries a FILE there". +fn scm_env_rewritten_root_document() -> JsonValue { let orphan = scm_env_put(store: empty_store(), n: scm_env_program_two()) let held = scm_env_put(store: empty_store(), n: scm_env_program_one()) let valid = scm_env_minted_or( @@ -533,7 +566,7 @@ fn scm_env_absent_root_repository() -> RepositoryEnvelope { message: "dangling", parent: none, ) - let rewritten = scm_env_commit_members_mapped( + scm_env_commit_members_mapped( v: encode_repository(repo: valid), f: c => scm_env_with( v: c, @@ -546,10 +579,23 @@ fn scm_env_absent_root_repository() -> RepositoryEnvelope { ]), ), ) - match decode_repository(v: rewritten) { - RepositoryDecoded { envelope: e } => e - RepositoryDecodeRefused { cause: _ } => empty_repository() - } +} + +test fn scm_env_a_commit_root_absent_from_the_object_table_refuses_to_decode() -> Bool { + scm_env_decode_cause_tag(outcome: decode_repository(v: scm_env_rewritten_root_document())) + == "commit_root_absent" +} + +// CONTROL, one fact apart: the same document with its root left alone decodes. +test fn scm_env_the_same_document_with_its_own_root_decodes() -> Bool { + let held = scm_env_put(store: empty_store(), n: scm_env_program_one()) + let valid = scm_env_minted_or( + repo: scm_env_repository_with_store(store: held.store), + root: held.identity, + message: "dangling", + parent: none, + ) + scm_env_decode_cause_tag(outcome: decode_repository(v: encode_repository(repo: valid))) == "decoded" } // A checkout naming an object that IS in the store but is NOT any commit's root. diff --git a/dag/test/fixture/scm_repository_load/empty_repository.json b/dag/test/fixture/scm_repository_load/empty_repository.json index d15f2ae668a..7be2073aebd 100644 --- a/dag/test/fixture/scm_repository_load/empty_repository.json +++ b/dag/test/fixture/scm_repository_load/empty_repository.json @@ -1,6 +1,6 @@ { - "format": "gunbc-scm-repository-v1", - "identity_rule": "fnv1a64-structural-canonical-v1", + "format": "gunbc-scm-repository-v2", + "identity_rule": "fnv1a64-structural-canonical-v2", "objects": [], "next_commit_ordinal": 0, "commits": [], From fc101067a53bdf3f4fb416bb70a2ef97e6ca9cdb Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 11:54:30 +0000 Subject: [PATCH 08/25] Name the identity rule for its subject, and freeze a predecessor document to prove the version refuses as a protocol gap Two items from the designated SCM reviewer's bar that the tag bump left half-done. THE RULE'S NAME SAID `node` WHILE ITS TABLE HELD NON-NODE IDENTITIES. `repository_node_identity_rule_tag` is the one authority in the document whose whole job is to say WHICH derivation reconstructed these identities, and it asserted something false about its own subject the moment the object table grew an authored-source arm -- a meaning fork in the worst possible place. It is now `repository_object_identity_rule_tag`, and its value names the rule rather than one branch's hash function: `scm-object-identity-v1`, a total law over the object coproduct whose semantic-node branch delegates to v2.std.node content_hash. A manifest branch joins this tag when it lands rather than minting a third. THE OLD-WRITER EVIDENCE, RESHAPED BY THE VERSIONING RATHER THAN DROPPED. The re-review asked for a frozen predecessor document -- bytes not produced by the current encoder -- to prove old documents still read. Bumping the tag changed what that evidence has to show: a v2 document is now a version this build does not implement, so the required behaviour is a REFUSAL. Specifically the unsupported-protocol one and never malformed, because those have opposite remedies and malformed is the ONE standing that permits supersession (gunbc.scm.supersession standing_permission_refusal) -- a reader that filed a predecessor document as damaged could discard a generation it merely cannot read. So three claims, not one: the cause is the format's own, the STANDING is the protocol gap, and the identical document under this build's tag loads. The document is a hand-authored literal precisely so nothing in the file can quietly re-derive it from the encoder under test, which is what made the existing round trips unable to answer this question. Instrument: claim_batch over every dag/test/claim/scm/*_witness_test.dag with each file's own `test fn` roster. 302 pass, 0 fail, exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/repository_envelope.dag | 27 ++++--- ...cm_commit_closure_json_v2_witness_test.dag | 76 +++++++++++++++++++ .../scm_repository_envelope_witness_test.dag | 2 +- .../scm_repository_load/empty_repository.json | 2 +- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index eb57a93b312..f989b2a0aa3 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -153,13 +153,22 @@ data repository_envelope_format_tag: String = "gunbc-scm-repository-v2" // deliberately NOT derived from the code that implements the rule: a tag computed from the current // implementation would agree with itself by construction and could never disagree, which is the // change-detector failure -- it would go green precisely when the rule changed. -// THE IDENTITY RULE MOVES ON ITS OWN AXIS, AND IT MOVED. v1 named ONE derivation: fnv1a64 over the -// canonicalized node structure. The store now derives a second family -- an authored source's -// identity, from content_hash_tagged_structural over a domain tag and the bytes -- and a document -// carrying both families is reconstructed by a rule v1 does not describe. A reader that accepted v1 -// here and then met a source entry would reconstruct identities by a rule the document never -// claimed, which is precisely the silent reinterpretation this tag exists to turn into a refusal. -data repository_node_identity_rule_tag: String = "fnv1a64-structural-canonical-v2" +// THE IDENTITY RULE MOVES ON ITS OWN AXIS, AND IT MOVED. The old value named ONE derivation -- +// fnv1a64 over the canonicalized node structure -- and the store now derives a second family: an +// authored source's identity, from content_hash_tagged_structural over a domain tag and the text. A +// document carrying both families is reconstructed by a rule the old value does not describe, and a +// reader that accepted it and then met a source entry would reconstruct identities by a rule the +// document never claimed. That is the silent reinterpretation this tag exists to turn into a +// refusal. +// +// THE NAME MOVED WITH IT, AND THAT IS NOT COSMETIC. The symbol said `node` while the table it +// governs holds non-node identities, so the authority's own name asserted something false about its +// subject -- the meaning fork DESIGN section 3 names, in the one place whose whole job is to say +// which rule was used. It now names the OBJECT identity rule, and the value names the rule rather +// than one branch's hash function, because the rule is a total law over the object coproduct whose +// semantic-node branch delegates to v2.std.node content_hash. A manifest branch joins this same tag +// when it lands; it does not mint a third one. +data repository_object_identity_rule_tag: String = "scm-object-identity-v1" // ANCESTRY IS A COMMIT REFERENCE, NOT A NODE REFERENCE, and that is this module's own ruling reused // rather than a fresh choice. The annotation on `checked_out` records why it resolves against the @@ -483,7 +492,7 @@ fn encode_repository_v1(repo: RepositoryEnvelope) -> JsonValue { json_object( members: [ json_kv(key: "format", value: json_string(s: repository_envelope_format_tag)), - json_kv(key: "identity_rule", value: json_string(s: repository_node_identity_rule_tag)), + json_kv(key: "identity_rule", value: json_string(s: repository_object_identity_rule_tag)), json_kv(key: "objects", value: json_array(elements: acc.objects)), json_kv( key: "next_commit_ordinal", @@ -1067,7 +1076,7 @@ fn decode_repository_v1(v: JsonValue) -> RepositoryDecodeOutcome { match read_string_member(v: v, key: "identity_rule") { MemberFailed { cause: c } => RepositoryDecodeRefused { cause: RepositoryMemberReadRefused { cause: c } } MemberString { value: rule } => - if rule != repository_node_identity_rule_tag { + if rule != repository_object_identity_rule_tag { RepositoryDecodeRefused { cause: RepositoryIdentityRuleUnrecognized { found: rule } } } else { match repository_member_set_refusal( diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index b73fb75094c..64385c4fb1c 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -87,6 +87,14 @@ import gunbc.scm.checkout { ObjectIsAuthoredSource, } import gunbc.scm.commit_closure { CommitClosure } +import gunbc.scm.load_standing { + LoadStanding, + LoadComplete, + LoadPartial, + LoadUnsupportedProtocol, + LoadDocumentMalformed, + LoadIdentityCollision, +} import gunbc.scm.merge { MergeOutcome, Merged, @@ -119,6 +127,7 @@ import gunbc.scm.commit_closure_json_v2 { ClosureDocFormatUnrecognized, ClosureDocRootPositionUnresolved, ClosureDocRootIsAuthoredSource, + closure_document_load_standing, ClosureDocMemberReadRefused, ClosureDocUnexpectedMember, ClosureDocTargetReferenceRefused, @@ -373,6 +382,73 @@ test fn scm_image_an_unrecognized_format_refuses() -> Bool { ) } +// THE PREDECESSOR'S OWN TAG, FROZEN, AND REFUSED AS A PROTOCOL GAP RATHER THAN AS DAMAGE. +// +// The re-review asked for independent old-writer evidence: bytes not produced by the current +// encoder. Versioning changed what that evidence has to show. Before the bump, the question was +// whether a predecessor document still decoded; now the v2 tag is a version this build does not +// implement, so the required behaviour is a REFUSAL -- and specifically the unsupported-protocol +// one, never malformed, because those have opposite remedies and only malformed permits +// supersession (gunbc.scm.supersession standing_permission_refusal). A reader that filed a +// predecessor document as damaged could discard a generation it simply cannot read. +// +// The document below is authored as a literal rather than derived from the current encoder, which +// is the whole point: it is what the predecessor emitted -- a node-only object table under the v2 +// tag -- and nothing in this file can quietly re-derive it. +data scm_image_legacy_v2_tag: String = "gunbc-scm-commit-closure-v2" + +fn scm_image_legacy_v2_document() -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: scm_image_legacy_v2_tag)), + json_kv(key: "objects", value: json_array(elements: [scm_image_empty_node_entry()])), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "0"))]) + ), + ] + ) +} + +test fn scm_image_a_frozen_predecessor_document_refuses_as_its_format() -> Bool { + scm_image_cause_tag(loaded: decode_closure_document(v: scm_image_legacy_v2_document())) == "format" +} + +// AND THE STANDING IS THE PROTOCOL ONE. The claim above pins the cause; this pins what a consumer +// deciding whether to overwrite is told, which is the half that actually carries the consequence. +test fn scm_image_a_frozen_predecessor_document_is_a_protocol_gap_not_damage() -> Bool { + match decode_closure_document(v: scm_image_legacy_v2_document()) { + ClosureDocLoaded { store: _, root: _ } => false + ClosureDocRefused { cause: c } => + match closure_document_load_standing(cause: c) { + LoadUnsupportedProtocol => true + LoadComplete => false + LoadPartial => false + LoadDocumentMalformed => false + LoadIdentityCollision => false + } + } +} + +// CONTROL, one fact apart: the identical document under THIS build's tag loads. Without it, a +// decoder that refused every hand-authored document would satisfy both claims above. +test fn scm_image_the_same_document_under_the_current_tag_loads() -> Bool { + scm_image_cause_tag( + loaded: decode_closure_document( + v: json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv(key: "objects", value: json_array(elements: [scm_image_empty_node_entry()])), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "0"))]) + ), + ] + ) + ) + ) == "loaded" +} + test fn scm_image_a_missing_format_refuses() -> Bool { scm_image_refused( loaded: decode_closure_document(v: scm_image_without(v: scm_image_encoded(n: closure_doc_program()), key: "format")) diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index ef7e4dadb21..39e162deae6 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -40,7 +40,7 @@ import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommit, RepositoryDecodeOutcome, RepositoryDecoded, RepositoryDecodeRefused, empty_repository, encode_repository, decode_repository, - repository_envelope_format_tag, repository_node_identity_rule_tag, + repository_envelope_format_tag, repository_object_identity_rule_tag, RepositoryEncodeOutcome, RepositoryEncoded, RepositoryEncodeRefused, RepositoryEncodeRefusal, RepositoryEncodeUncontainedTarget, RepositoryCommitRootNotInStore, RepositoryCheckoutNotInCommits, diff --git a/dag/test/fixture/scm_repository_load/empty_repository.json b/dag/test/fixture/scm_repository_load/empty_repository.json index 7be2073aebd..8b44c5f48e5 100644 --- a/dag/test/fixture/scm_repository_load/empty_repository.json +++ b/dag/test/fixture/scm_repository_load/empty_repository.json @@ -1,6 +1,6 @@ { "format": "gunbc-scm-repository-v2", - "identity_rule": "fnv1a64-structural-canonical-v2", + "identity_rule": "scm-object-identity-v1", "objects": [], "next_commit_ordinal": 0, "commits": [], From dce2a40d7976b762e9e375f576aa6542a138624e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 12:06:20 +0000 Subject: [PATCH 09/25] The module names the codec and the tag names the version: retire a suffix that had started to fork meaning `gunbc.scm.commit_closure_json_v2` announced v2 in its name while emitting `gunbc-scm-commit-closure-v3` in its bytes. A reader has no way to decide which of the two is the contract, and that is the meaning fork DESIGN section 3 forbids sitting in the one authority whose whole job is to say which schema this build writes. The suffix was not wrong when it was authored: it named the second CODEC GENERATION, distinguishing this module from the predecessor that encoded the root positionally. That predecessor is deleted, so the suffix had already stopped distinguishing anything, and bumping the format tag turned a harmless fossil into an active contradiction. The fix is the ordinary one -- one fact per authority: the module names the codec, the tag names the version, and the version lives in a value the encoder actually emits rather than in a spelling in a path. Renamed with it: the module, its file, the witness module and file, and every prose citation across the corpus. A citation naming a symbol that no longer exists is the positional-citation failure by another route -- it decays silently and nothing refuses. Found by the designated SCM reviewer while tracing whether retired version names still fork meaning after the tag bump. They did. Instrument: claim_batch over every dag/test/claim/scm/*_witness_test.dag with each file's own `test fn` roster. 302 pass, 0 fail, exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/non_fold_residue.dag | 4 ++-- dag/gunbc/recurring_failure_mode.dag | 2 +- dag/gunbc/scm/commit_closure.dag | 2 +- ...closure_json_v2.dag => commit_closure_json.dag} | 14 +++++++++++++- dag/gunbc/scm/json_member.dag | 2 +- dag/gunbc/scm/object_table_json.dag | 2 +- dag/gunbc/scm/repository_envelope.dag | 14 +++++++------- dag/gunbc/scm/supersession.dag | 2 +- ...ag => scm_commit_closure_json_witness_test.dag} | 6 +++--- .../claim/scm/scm_commit_closure_witness_test.dag | 2 +- .../claim/scm/scm_load_standing_witness_test.dag | 2 +- dag/test/claim/scm/scm_merge_witness_test.dag | 2 +- .../scm/scm_repository_envelope_witness_test.dag | 4 ++-- 13 files changed, 35 insertions(+), 23 deletions(-) rename dag/gunbc/scm/{commit_closure_json_v2.dag => commit_closure_json.dag} (98%) rename dag/test/claim/scm/{scm_commit_closure_json_v2_witness_test.dag => scm_commit_closure_json_witness_test.dag} (99%) diff --git a/dag/gunbc/non_fold_residue.dag b/dag/gunbc/non_fold_residue.dag index ceb983cc4e3..57f840a663a 100644 --- a/dag/gunbc/non_fold_residue.dag +++ b/dag/gunbc/non_fold_residue.dag @@ -128,7 +128,7 @@ data nfr_reason_build_axis_reading_unreachable_arm: String = "axis-reading proje data nfr_dissolve_build_axis_reading_refined_carrier: DissolutionCondition = unbound_dissolution(description: "BuildCandidate carries its cash and ceiling on a refined Derived-only carrier once candidate_missing_inputs has admitted it (illegal states unrepresentable at the reading), so the readings match totally over the refined carrier and the unreachable arms delete with their rows") -data nfr_reason_connective_payload_encoding: String = "one-special-variant dispatch over Connective: Atom carries an identity payload and encodes two members, and the five nullary connectives share one tag-only member through connective_tag โ€” enumerating them would clone the tag-only arm per variant (the DESIGN ยง2 redundancy, not the DESIGN ยง4 totality); same shape as the orchestration-emit row. Landed with the commit_closure_json_v2 format cut (#8965, 2026-08-23) unrostered and surfaced by the whole-corpus nfr receipt (cli_run nfr_roster_receipt) on 2026-08-29; declared so the ratchet re-arms" +data nfr_reason_connective_payload_encoding: String = "one-special-variant dispatch over Connective: Atom carries an identity payload and encodes two members, and the five nullary connectives share one tag-only member through connective_tag โ€” enumerating them would clone the tag-only arm per variant (the DESIGN ยง2 redundancy, not the DESIGN ยง4 totality); same shape as the orchestration-emit row. Landed with the commit_closure_json format cut (#8965, 2026-08-23) unrostered and surfaced by the whole-corpus nfr receipt (cli_run nfr_roster_receipt) on 2026-08-29; declared so the ratchet re-arms" data nfr_dissolve_connective_payload_encoding: DissolutionCondition = unbound_dissolution(description: "the closure encoding becomes the backward grammar-row fold (DESIGN ยง4) or Connective carries a derived payload-arity projection from inhabitance, so the payload-bearing arm is a named partition case rather than a wildcard, and the row deletes with that fold") @@ -1016,7 +1016,7 @@ data non_fold_residue_frontier: List = [ dissolution: nfr_dissolve_build_axis_reading_refined_carrier, }, FrontierRow { - subject: PathSubject { path: "dag/gunbc/scm/commit_closure_json_v2.dag::encode_connective" }, + subject: PathSubject { path: "dag/gunbc/scm/commit_closure_json.dag::encode_connective" }, reason: nfr_reason_connective_payload_encoding, dissolution: nfr_dissolve_connective_payload_encoding, }, diff --git a/dag/gunbc/recurring_failure_mode.dag b/dag/gunbc/recurring_failure_mode.dag index 5af67150fd8..b13616e7a0c 100644 --- a/dag/gunbc/recurring_failure_mode.dag +++ b/dag/gunbc/recurring_failure_mode.dag @@ -140,7 +140,7 @@ data reachability_read_as_occupancy: RecurringFailureMode = RecurringFailureMode data total_at_the_level_examined_blind: RecurringFailureMode = RecurringFailureMode { identity: "total_at_the_level_examined_blind" as NonEmptyStr, - authored: "**total at the level examined, blind one level down** (a judgment is exhaustive over the type it matches and says nothing about the distinction that actually decides the outcome, which lives inside one of its arms. **The pairing is the whole content: the match is exhaustive AND wrong**, and the exhaustiveness is what hides it โ€” there is no missing arm to report, no warning, nothing incomplete, so neither a reviewer reading the match nor a wildcard-free lens can see it. Specimen with a mutation receipt (gunbc.scm, 2026-08-22), **repaired on main 2026-08-25 by gunbc#8973 and kept in the past tense because the mechanism recurs while this instance does not**: a publication fold matched one outer arm carrying a JSON codec's entire refusal enum. It was total over that codec's refusal type, and the enum's arms carried three different publication remedies โ€” a reader limitation, an absent-object problem over a well-formed document, and malformed bytes. Only the third may supersede a head slot, so the collapsed form let an OLDER READER OVERWRITE A NEWER WRITER'S DOCUMENT: data loss produced by the reader's own limitation, from a match that compiled and looked complete. Executed both ways โ€” descending, four witnesses passed; collapsed, the witness driving all three remedies through the single outer arm went red; a temporary decode arm made the fold non-exhaustive; restored control passed. **None of this row's three cited symbols resolved on main, and they failed in two different ways** โ€” measured across main's whole history, not just its head. `repository_load_disposition`, the function this row said performed the collapse, has NEVER existed as code on main: it appears only in this carrier and in the projection, and it names a declaration from the CLOSED attempt gunbc#8940, whose branch was retained in history and never landed. So the row cited a function that was fabricated on the day it was written. `RepositoryDecodeImageRefusal` and `ImageRefusal` were real โ€” they landed with gunbc#8807 and gunbc#8840 and were deleted by gunbc#8965 on 2026-08-23 when the fused format was cut โ€” so those two went stale one day later, by the ordinary route a citation rots when the thing it names is removed. The two halves matter separately because they have different remedies: a fabricated name is caught by resolving symbols before writing them, a deleted one only by a resolver that reruns. Either way a reader applying this entry's own recognition rule grepped for a match that was not there. That is ยง3's stale-citation class sitting inside the authority that names it, and it is recorded here rather than quietly deleted because a specimen whose symbols cannot be resolved is the failure this row exists to teach, one level up. **What landed is stronger than descending**, and the difference is the reusable part: the remedy vocabulary was hoisted into a module that names no format โ€” `gunbc.scm.load_standing` `LoadStanding`, and each format now classifies its own refusals into it (`gunbc.scm.commit_closure_json_v2` `closure_document_load_standing`). Descending inside one codec's enum would have closed the collapse and left the consumer still matching on a format's vocabulary; hoisting leaves the consumer no format arms to collapse, so the next format is a new classifier rather than a new way to reintroduce this defect. **THE HOISTING WAS NECESSARY AND NOT SUFFICIENT, and this sentence is the fourth citation in this row to have gone stale.** It read that the permission was derived `by one total function`, naming `standing_may_supersede_generation` โ€” a `LoadStanding -> Bool` carrying neither the slot the observation was made on nor the generation examined, so a caller could apply it to a slot the evidence never described or to a generation that had moved since it was read. gunbc#9258 deleted it and replaced the destination with `gunbc.scm.supersession` `supersede_head_slot`, where evidence is minted FROM the slot by `observe_generation` โ€” a caller cannot state which generation it examined, only examine one โ€” and the swap refuses unless the slot still holds it. The deletion left this sentence naming a declaration that no longer resolves, one day after it was written, which is the same rot this row already records twice above and is corrected here for the same reason. Second, independent specimen the same day, in the compiler itself: a census of the fourteen declared-type positions the grammar admits found five accepting a plain kernel value where a coproduct is declared โ€” the judgment is present and total at the level it examines, and never descends. **The tell is a `_` or a bare binding in the arm that carries the payload**, and the test is not whether the match is complete but whether every cause it collapses shares one remedy; the fix is to descend so a new inner arm fails to compile HERE, rather than silently inheriting whichever remedy the outer arm happened to name.)", + authored: "**total at the level examined, blind one level down** (a judgment is exhaustive over the type it matches and says nothing about the distinction that actually decides the outcome, which lives inside one of its arms. **The pairing is the whole content: the match is exhaustive AND wrong**, and the exhaustiveness is what hides it โ€” there is no missing arm to report, no warning, nothing incomplete, so neither a reviewer reading the match nor a wildcard-free lens can see it. Specimen with a mutation receipt (gunbc.scm, 2026-08-22), **repaired on main 2026-08-25 by gunbc#8973 and kept in the past tense because the mechanism recurs while this instance does not**: a publication fold matched one outer arm carrying a JSON codec's entire refusal enum. It was total over that codec's refusal type, and the enum's arms carried three different publication remedies โ€” a reader limitation, an absent-object problem over a well-formed document, and malformed bytes. Only the third may supersede a head slot, so the collapsed form let an OLDER READER OVERWRITE A NEWER WRITER'S DOCUMENT: data loss produced by the reader's own limitation, from a match that compiled and looked complete. Executed both ways โ€” descending, four witnesses passed; collapsed, the witness driving all three remedies through the single outer arm went red; a temporary decode arm made the fold non-exhaustive; restored control passed. **None of this row's three cited symbols resolved on main, and they failed in two different ways** โ€” measured across main's whole history, not just its head. `repository_load_disposition`, the function this row said performed the collapse, has NEVER existed as code on main: it appears only in this carrier and in the projection, and it names a declaration from the CLOSED attempt gunbc#8940, whose branch was retained in history and never landed. So the row cited a function that was fabricated on the day it was written. `RepositoryDecodeImageRefusal` and `ImageRefusal` were real โ€” they landed with gunbc#8807 and gunbc#8840 and were deleted by gunbc#8965 on 2026-08-23 when the fused format was cut โ€” so those two went stale one day later, by the ordinary route a citation rots when the thing it names is removed. The two halves matter separately because they have different remedies: a fabricated name is caught by resolving symbols before writing them, a deleted one only by a resolver that reruns. Either way a reader applying this entry's own recognition rule grepped for a match that was not there. That is ยง3's stale-citation class sitting inside the authority that names it, and it is recorded here rather than quietly deleted because a specimen whose symbols cannot be resolved is the failure this row exists to teach, one level up. **What landed is stronger than descending**, and the difference is the reusable part: the remedy vocabulary was hoisted into a module that names no format โ€” `gunbc.scm.load_standing` `LoadStanding`, and each format now classifies its own refusals into it (`gunbc.scm.commit_closure_json` `closure_document_load_standing`). Descending inside one codec's enum would have closed the collapse and left the consumer still matching on a format's vocabulary; hoisting leaves the consumer no format arms to collapse, so the next format is a new classifier rather than a new way to reintroduce this defect. **THE HOISTING WAS NECESSARY AND NOT SUFFICIENT, and this sentence is the fourth citation in this row to have gone stale.** It read that the permission was derived `by one total function`, naming `standing_may_supersede_generation` โ€” a `LoadStanding -> Bool` carrying neither the slot the observation was made on nor the generation examined, so a caller could apply it to a slot the evidence never described or to a generation that had moved since it was read. gunbc#9258 deleted it and replaced the destination with `gunbc.scm.supersession` `supersede_head_slot`, where evidence is minted FROM the slot by `observe_generation` โ€” a caller cannot state which generation it examined, only examine one โ€” and the swap refuses unless the slot still holds it. The deletion left this sentence naming a declaration that no longer resolves, one day after it was written, which is the same rot this row already records twice above and is corrected here for the same reason. Second, independent specimen the same day, in the compiler itself: a census of the fourteen declared-type positions the grammar admits found five accepting a plain kernel value where a coproduct is declared โ€” the judgment is present and total at the level it examines, and never descends. **The tell is a `_` or a bare binding in the arm that carries the payload**, and the test is not whether the match is complete but whether every cause it collapses shares one remedy; the fix is to descend so a new inner arm fails to compile HERE, rather than silently inheriting whichever remedy the outer arm happened to name.)", evidence: [], } diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index 50afcaf6e34..33c4a1f4a2b 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -247,7 +247,7 @@ fn unresolved_identities(closure: CommitClosure) -> List { // have are checked separately against the commit list. Folding the root into this would have forced // the repository to invent one. // UNCONTAINED TARGETS ACCUMULATE BY PREPEND AND ARE REVERSED ONCE, the same idiom this stack applies -// in commit_closure_json_v2 encode_step and in repository_envelope's commit fold. It is restated at +// in commit_closure_json encode_step and in repository_envelope's commit fold. It is restated at // the third site because the first two were not enough: this module documented the rule and then // reintroduced the shape it forbids, which is the ยง6 point exactly -- fixing a cost shape where a // review names it is not the same as not having the defect, and the sweep has to cover every diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json.dag similarity index 98% rename from dag/gunbc/scm/commit_closure_json_v2.dag rename to dag/gunbc/scm/commit_closure_json.dag index fee251a1e9f..c2fff6aacab 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json.dag @@ -1,4 +1,16 @@ -module gunbc.scm.commit_closure_json_v2 +module gunbc.scm.commit_closure_json + +// THE MODULE NAME CARRIED A VERSION AND THE DOCUMENT CARRIED A DIFFERENT ONE, which is a meaning +// fork in the place least able to afford it. It was `commit_closure_json_v2`, a suffix that named +// the second CODEC generation -- the predecessor that encoded the root positionally, since deleted. +// With that sibling gone the suffix distinguished nothing, and once the schema grew a member and the +// tag became `โ€ฆ-v3`, one authority was announcing v2 in its name and v3 in its bytes. A reader has no +// way to know which of the two is the contract. +// +// So the module names the CODEC and the tag names the VERSION, one fact each. There is exactly one +// place to look for which schema this build writes, and it is a value the encoder actually emits +// rather than a spelling in a path. Named by the designated SCM reviewer while tracing whether +// retired version names still fork meaning; they did. import std.types { Bool, Int, List, String, list_length } diff --git a/dag/gunbc/scm/json_member.dag b/dag/gunbc/scm/json_member.dag index 700a585bceb..229b79a363e 100644 --- a/dag/gunbc/scm/json_member.dag +++ b/dag/gunbc/scm/json_member.dag @@ -1,7 +1,7 @@ module gunbc.scm.json_member // READING A NAMED MEMBER OFF A JSON OBJECT IS ONE OPERATION WITH ONE FAILURE POPULATION, and this -// module owns both. It was previously homed inside gunbc.scm.commit_closure_json_v2 with its failure +// module owns both. It was previously homed inside gunbc.scm.commit_closure_json with its failure // typed as THAT module's ClosureDocRefusal, which is the layer inversion DESIGN section 3 names: a // domain-neutral facility carrying a domain's vocabulary. The measured cost was not stylistic. Every // repository-envelope member read -- `checked_out`, `identity_rule`, `root`, `message` -- had to diff --git a/dag/gunbc/scm/object_table_json.dag b/dag/gunbc/scm/object_table_json.dag index 7f95384a2f2..4af5fc0af5c 100644 --- a/dag/gunbc/scm/object_table_json.dag +++ b/dag/gunbc/scm/object_table_json.dag @@ -1,7 +1,7 @@ module gunbc.scm.object_table_json // DECODING THE OBJECT TABLE IS ONE OPERATION WITH ONE FAILURE POPULATION, and this module owns both. -// It was previously homed inside gunbc.scm.commit_closure_json_v2 with its failures typed as THAT +// It was previously homed inside gunbc.scm.commit_closure_json with its failures typed as THAT // module's ClosureDocRefusal, so six object-population causes sat in a coproduct that also answers // for envelope format and closure-root facts. A consumer matching that type had to handle causes // from three different owners, and no reader could tell from the type which owner produced which. diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index f989b2a0aa3..7bf446c5899 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -1,8 +1,8 @@ module gunbc.scm.repository_envelope -// THE REPOSITORY ENVELOPE: the layer that gunbc.scm.commit_closure_json_v2 deliberately is not. +// THE REPOSITORY ENVELOPE: the layer that gunbc.scm.commit_closure_json deliberately is not. // -// commit_closure_json_v2 holds ONE ROOT AND ITS CLOSURE. A repository holds facts that carrier cannot express, +// commit_closure_json holds ONE ROOT AND ITS CLOSURE. A repository holds facts that carrier cannot express, // and each one is a reason this module exists rather than a field bolted onto that one: // // - AN EMPTY INITIALIZED STATE. `init` produces a repository with no commits and no root at all. @@ -94,7 +94,7 @@ import gunbc.scm.object_store { find_node_record, } import gunbc.scm.commit_closure { uncontained_targets } -import gunbc.scm.commit_closure_json_v2 { +import gunbc.scm.commit_closure_json { ClosureDocRefusal, DecodeAcc, DecodePositions, @@ -133,7 +133,7 @@ import gunbc.scm.load_standing { // -- which is canonical because the digests are already content-derived. That is a real change to // encode_object_table rather than a flag, so it is a separate slice, not a footnote. // v2, FOR TWO CHANGES THAT ARE BOTH SCHEMA CHANGES BY THIS STACK'S OWN RULE. The embedded object -// table is gunbc.scm.commit_closure_json_v2's, which now admits authored-source entries, so a v1 +// table is gunbc.scm.commit_closure_json's, which now admits authored-source entries, so a v1 // reader refuses documents this writer can produce. And a commit's `root` is now RESOLVED against // that table at decode -- a root the table does not carry, or carries as a file, refuses here rather // than being admitted and noticed later -- so the set of documents that decode is different in both @@ -266,7 +266,7 @@ type RepositoryDecodeRefusal // THE CONTEXT VOCABULARY IS THIS LAYER'S, THE MEMBERSHIP MECHANISM IS SHARED. gunbc.scm.json_member owns // `unexpected_member_key`, which answers a context-free question -- which key is not in this set -- -// and each layer names its own shapes. Adding `RepositoryEnvelopeMembers` to commit_closure_json_v2's context +// and each layer names its own shapes. Adding `RepositoryEnvelopeMembers` to commit_closure_json's context // coproduct would have made the lower carrier enumerate the shapes of a layer above it, which is the // inversion DESIGN section 3 names; duplicating the fold here instead would have been a second // authority for one rule. Neither is necessary: the mechanism generalizes, the vocabulary does not. @@ -700,7 +700,7 @@ fn unresolved_checkout_selection(commits: List, selected: Repo // COMMITS ACCUMULATE BY PREPEND AND ARE REVERSED ONCE AT THE CONSUMER. concat-at-end inside a fold // copies the whole accumulator per commit, which is the quadratic shape DESIGN section 6 says is -// always fixed regardless of the realized n. This stack removed that shape from commit_closure_json_v2 and from +// always fixed regardless of the realized n. This stack removed that shape from commit_closure_json and from // object_store.dag and then reintroduced it here in the same diff -- caught by review, and the point // worth recording is that fixing a cost shape at the sites a review names is not the same as not // having the defect: the sweep has to be over every accumulator the change touches, or the rule is @@ -1207,7 +1207,7 @@ fn decode_repository_checkout( // THE SECOND FORMAT'S CLASSIFIER, AND IT WAS MISSING RATHER THAN UNNECESSARY. // -// gunbc.scm.commit_closure_json_v2 and gunbc.scm.object_table_json each classify their own refusals +// gunbc.scm.commit_closure_json and gunbc.scm.object_table_json each classify their own refusals // into the format-agnostic vocabulary; this document format did not, so any consumer deciding what to // do with a refused repository load had to reach into RepositoryDecodeRefusal and assign a standing // itself -- one format's error enum answering a format-agnostic question, which is the placement diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index 9933f9bc951..34afc5a804b 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -140,7 +140,7 @@ fn observe_generation( // destination's whole producer side, not any one defect site. Measured on the live tree rather than // recalled, by enumerating every declaration that returns a `LoadStanding`. // -// PRODUCERS, TOTAL: two, both in `gunbc.scm.commit_closure_json_v2`. +// PRODUCERS, TOTAL: two, both in `gunbc.scm.commit_closure_json`. // `closure_document_load_standing` classifies one format's refusals. It descends everywhere a // payload could hide a second distinction -- through `MemberReadRefusal`'s four modes and // `TargetReferenceRefusal`'s four -- and is the ONLY declaration in the tree that can return diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_witness_test.dag similarity index 99% rename from dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag rename to dag/test/claim/scm/scm_commit_closure_json_witness_test.dag index 64385c4fb1c..22eb219eafd 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_witness_test.dag @@ -1,11 +1,11 @@ -module test.claim.scm_commit_closure_json_v2_witness +module test.claim.scm_commit_closure_json_witness // The durable repository image, executed. The claim under test is that a store survives the round // trip to text and back AS THE SAME PROGRAM, and that every way the text can lie REFUSES rather than // producing a plausible store. // // The identity claims here are the load-bearing ones. The image carries NO object identities -- see -// gunbc.scm.commit_closure_json_v2's header for why -- so what these witnesses establish is that identity survives +// gunbc.scm.commit_closure_json's header for why -- so what these witnesses establish is that identity survives // anyway, because it is re-derived from structure on load. A round trip that merely produced "some // store" would satisfy a weaker test; comparing the reconstructed PROGRAM through checkout is what // makes it a claim about meaning rather than about bytes. @@ -118,7 +118,7 @@ import gunbc.scm.object_table_json { ObjectTableEdgeTargetIsAuthoredSource, ObjectTableObjectCollision, } -import gunbc.scm.commit_closure_json_v2 { +import gunbc.scm.commit_closure_json { ClosureDocObjectTableRefused, ClosureDocLoadOutcome, ClosureDocLoaded, diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index f59bceaea09..c3c65295540 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -50,7 +50,7 @@ import gunbc.scm.target_reference { TargetReferenceMemberReadRefused, decode_target_reference, } -import gunbc.scm.commit_closure_json_v2 { +import gunbc.scm.commit_closure_json { ClosureDocRefusal, ClosureDocIncompleteWithoutAdmission, ClosureDocEncodeRefusal, diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index dff1d6292e8..3127e0bdaf7 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -49,7 +49,7 @@ import gunbc.scm.repository_envelope { RepositoryCommitRootReferenceUnresolved, repository_envelope_load_standing, } -import gunbc.scm.commit_closure_json_v2 { +import gunbc.scm.commit_closure_json { ClosureDocRefusal, ClosureDocFormatUnrecognized, ClosureDocObjectTableRefused, ClosureDocMemberReadRefused, ClosureDocUnexpectedMember, ClosureDocEnvelopeMembers, diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 914133eff77..0d8de82c5c1 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -673,7 +673,7 @@ fn merge_witness_source_fixture() -> Fixture { // supplies is stored through store_node and is a semantic node by construction. The only way a role // value names a file is for the TARGET ROOT's own child edge to point at one, and the only producer // of that shape is the decoder resolving an edge position into an authored entry. So that claim lives -// beside the document builder, in test.claim.scm_commit_closure_json_v2_witness, where the input can +// beside the document builder, in test.claim.scm_commit_closure_json_witness, where the input can // actually be constructed -- rather than here, where it would need a constructor the model correctly // does not offer. fn merge_refusal_tag(o: MergeOutcome) -> String { diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 39e162deae6..a776e3fe3ce 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -1,6 +1,6 @@ module test.claim.scm_repository_envelope_witness -// The repository envelope, executed. What is under test is the layer commit_closure_json_v2 deliberately is not: +// The repository envelope, executed. What is under test is the layer commit_closure_json deliberately is not: // an empty initialized repository with NO root, several commits over ONE shared node population, and // a checked-out selection that may be absent but may never dangle. // @@ -270,7 +270,7 @@ test fn scm_env_the_root_signature_distinguishes_order() -> Bool { // // This is the THIRD time this stack has shipped an observation structurally incapable of producing // its own negative, and the second time the identical defect was fixed one layer down and then -// reintroduced one layer up -- commit_closure_json_v2's sharing claim had exactly this shape and exactly this +// reintroduced one layer up -- commit_closure_json's sharing claim had exactly this shape and exactly this // repair. Recorded here rather than silently corrected, because the pattern is the finding: a claim // whose subject is an encoding must observe the ENCODED form, and deduplicating consumers make any // downstream count agree with itself. From 9fefe5537dd016beb9b83bbb9180da4ff05e372c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 12:45:07 +0000 Subject: [PATCH 10/25] Restore the rung-drop row an auto-merge deleted, and revert a module rename whose admission wave every other lane would have paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI findings, and the first is the one that matters. A DECLARED SAFETY REGRESSION DISAPPEARED IN A MERGE, WITH NO CONFLICT. `direct_call_arg_seam_v2_exemption` landed on main today: a ยง4b(3) row declaring that one of the two direct-call argument judgments is switched off for every `v2.*` module. Taking main into this branch deleted it -- the declaration and its roster entry -- and git reported a clean auto-merge. That is exactly the class main filed this morning as `merge_region_excludes_shared_tail`, whose own text names `gunbc.rung_drop` as the second carrier with the vulnerable shape: multi-line rows sharing a `}` tail, so the three-way merge factors the tail out of the conflict region and the region's natural resolution drops what lived in it. I resolved the file git DID conflict on and never looked at the one it resolved for me. The recognition rule that row states is about the shared suffix; the operational half I learned here is that the same carrier class also produces SILENT deletions on the files that never conflict at all. `gunbc.rung_drop` and `docs/design-ledgers.md` are restored to main's content, and the ledger is REGENERATED from the authorities rather than hand-restored, so what is committed is what the projection derives. THE MODULE RENAME IS REVERTED, AND THE REASON IS AN EXTERNALITY. `commit_closure_json_v2` -> `commit_closure_json` was right on its merits: after the tag bump the module announced v2 in its name and v3 in its bytes. It is reverted because of what it costs everyone else. A rename re-targets every binding naming it, which the required namespace-wave-admission phase classifies `TargetChanged` -- not auto-admitted, one roster row each, 41 here. That roster's own header records the receipt: `stale_admissions` is computed PER RUN, so rows sitting on main are inherited by every open pull request, and a change touching no namespace can never match them; the phase then refuses every unrelated lane until they are dissolved. Spending every other lane's CI to fix one module's name is the externalized cost DESIGN ยง5 names, and doing it inside a pull request about the object model compounds it. So the fork is REPORTED where a reader meets it: the module header now says which of the two versions is the contract (the emitted tag), that the suffix is a dead codec-generation fossil, what the rename costs, and a dissolve-on naming the shape that can pay it -- its own change, rows authored and deleted in one wave, at a moment the open-PR population can absorb. A comment does not make the name honest and this one does not claim to; it stops the two from looking equally authoritative. AND ONE REAL DEFECT THE FLOOR CAUGHT THAT LOCAL RUNS DID NOT. `NewUnresolvedness binding gunbc.scm.log::CommitLogRow ObjectId` -- the row still named `ObjectId` after that import was dropped, resolving locally through bare-reference closure while the namespace wall measured it against the base and saw a name that had lost its declarer. The field is now `SemanticNodeObjectRef`, which is what it actually holds: `RepositoryCommit.root` is branded, so the log row carries the reference rather than a bare address. Instrument: claim_batch over every dag/test/claim/scm/*_witness_test.dag with each file's own `test fn` roster. 302 pass, 0 fail, exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- DESIGN.md | 1 + dag/gunbc/non_fold_residue.dag | 4 +-- dag/gunbc/recurring_failure_mode.dag | 2 +- dag/gunbc/rung_drop.dag | 9 +++++ dag/gunbc/scm/commit_closure.dag | 2 +- ...re_json.dag => commit_closure_json_v2.dag} | 36 ++++++++++++------- dag/gunbc/scm/json_member.dag | 2 +- dag/gunbc/scm/log.dag | 2 +- dag/gunbc/scm/object_table_json.dag | 2 +- dag/gunbc/scm/repository_envelope.dag | 14 ++++---- dag/gunbc/scm/supersession.dag | 2 +- ...m_commit_closure_json_v2_witness_test.dag} | 6 ++-- .../scm/scm_commit_closure_witness_test.dag | 2 +- .../scm/scm_load_standing_witness_test.dag | 2 +- dag/test/claim/scm/scm_merge_witness_test.dag | 2 +- .../scm_repository_envelope_witness_test.dag | 4 +-- docs/design-ledgers.md | 4 +++ 17 files changed, 61 insertions(+), 35 deletions(-) rename dag/gunbc/scm/{commit_closure_json.dag => commit_closure_json_v2.dag} (96%) rename dag/test/claim/scm/{scm_commit_closure_json_witness_test.dag => scm_commit_closure_json_v2_witness_test.dag} (99%) diff --git a/DESIGN.md b/DESIGN.md index 16f265af2c6..f0d7cc54c5a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,6 +117,7 @@ At a service boundary, rung honesty has a commercial consequence: a dimension ma Every newly discovered error class โ€” incident, review finding, runtime exception, falsifier divergence โ€” files or updates one row: invalid state, harm, distinguishing facts, rung found at, ceiling with reason, next trigger. Declared drops are rostered in full โ€” previous rung, temporary rung, reason, population, restoration trigger โ€” in [docs/design-ledgers.md](docs/design-ledgers.md), authority `gunbc.rung_drop`. A drop is retired BY ITS TRIGGER AND BY NOTHING ELSE, so the trigger is the whole check. The ones standing today: - **Emitted-bytes fixture witnesses in a required lane** โ€” declared 2026-09-01 +- **Direct-call argument TYPE-COMPAT judgment inside v2.* modules (one of two arms; inhabitance still runs)** โ€” declared 2026-09-01 - **CI required-run composition** โ€” declared 2026-08-15 - **Self-host emission board measurement** โ€” declared 2026-08-24 - **Regen candidate-tree producer** โ€” declared 2026-08-20 diff --git a/dag/gunbc/non_fold_residue.dag b/dag/gunbc/non_fold_residue.dag index 57f840a663a..ceb983cc4e3 100644 --- a/dag/gunbc/non_fold_residue.dag +++ b/dag/gunbc/non_fold_residue.dag @@ -128,7 +128,7 @@ data nfr_reason_build_axis_reading_unreachable_arm: String = "axis-reading proje data nfr_dissolve_build_axis_reading_refined_carrier: DissolutionCondition = unbound_dissolution(description: "BuildCandidate carries its cash and ceiling on a refined Derived-only carrier once candidate_missing_inputs has admitted it (illegal states unrepresentable at the reading), so the readings match totally over the refined carrier and the unreachable arms delete with their rows") -data nfr_reason_connective_payload_encoding: String = "one-special-variant dispatch over Connective: Atom carries an identity payload and encodes two members, and the five nullary connectives share one tag-only member through connective_tag โ€” enumerating them would clone the tag-only arm per variant (the DESIGN ยง2 redundancy, not the DESIGN ยง4 totality); same shape as the orchestration-emit row. Landed with the commit_closure_json format cut (#8965, 2026-08-23) unrostered and surfaced by the whole-corpus nfr receipt (cli_run nfr_roster_receipt) on 2026-08-29; declared so the ratchet re-arms" +data nfr_reason_connective_payload_encoding: String = "one-special-variant dispatch over Connective: Atom carries an identity payload and encodes two members, and the five nullary connectives share one tag-only member through connective_tag โ€” enumerating them would clone the tag-only arm per variant (the DESIGN ยง2 redundancy, not the DESIGN ยง4 totality); same shape as the orchestration-emit row. Landed with the commit_closure_json_v2 format cut (#8965, 2026-08-23) unrostered and surfaced by the whole-corpus nfr receipt (cli_run nfr_roster_receipt) on 2026-08-29; declared so the ratchet re-arms" data nfr_dissolve_connective_payload_encoding: DissolutionCondition = unbound_dissolution(description: "the closure encoding becomes the backward grammar-row fold (DESIGN ยง4) or Connective carries a derived payload-arity projection from inhabitance, so the payload-bearing arm is a named partition case rather than a wildcard, and the row deletes with that fold") @@ -1016,7 +1016,7 @@ data non_fold_residue_frontier: List = [ dissolution: nfr_dissolve_build_axis_reading_refined_carrier, }, FrontierRow { - subject: PathSubject { path: "dag/gunbc/scm/commit_closure_json.dag::encode_connective" }, + subject: PathSubject { path: "dag/gunbc/scm/commit_closure_json_v2.dag::encode_connective" }, reason: nfr_reason_connective_payload_encoding, dissolution: nfr_dissolve_connective_payload_encoding, }, diff --git a/dag/gunbc/recurring_failure_mode.dag b/dag/gunbc/recurring_failure_mode.dag index 58d084d022f..cdd2e72ba5a 100644 --- a/dag/gunbc/recurring_failure_mode.dag +++ b/dag/gunbc/recurring_failure_mode.dag @@ -117,7 +117,7 @@ data authority_substitution: RecurringFailureMode = RecurringFailureMode { ident data reachability_read_as_occupancy: RecurringFailureMode = RecurringFailureMode { identity: "reachability_read_as_occupancy" as NonEmptyStr, authored: "**reachability read as occupancy** (a guard's arm, disposition or check is deleted because nothing currently lands in it. Three questions are in play and only the first two decide whether it should exist: *mechanism existence* โ€” can any governing authority produce this state; *join reachability* โ€” can that mechanism classify an element of THIS operation's denominator; *current occupancy* โ€” does anything receive it today. **Yes / yes / zero is a healthy guard being quiet, not a dead one**, and deleting it removes a live wall while looking principled. The distinction is load-bearing because it borders a true rule pointing the other way: a check whose RED IS UNREACHABLE โ€” no authorable input can produce it โ€” is a decoration, permanently green and worse than absent, because it is cited as coverage. Unreachable means the red cannot be produced at all; empty means it can and nothing does. Two corollaries, each from a lane that got it wrong first. Reachability is judged against what a FIXTURE MAY AUTHOR, never what the accepted corpus contains โ€” a regression probe is an invalid program by definition, so a state no `Accepted` program holds is usually still authorable as source, and scoping reachability to corpus-expressible is this same conflation one layer out. And an arm is reachable relative to ONE operation: `gunbc.scm`'s path-exclusion disposition is unreachable from the floor's own fold, which drops excluded subjects before inventory, yet reachable from a join over admission ROWS, which exclusion never removes โ€” so *the arm's reachability is a property of the denominator of the operation that returns it, not of what some lower mechanism observes internally*. Both halves were found the same day by separate lanes, from opposite directions. **THE DISCRIMINATING COUNTER-SPECIMEN, which this row needed and did not have (gunbc XL-0-RECEIPT, 2026-09-01):** `gunbc.floor_component_receipt` and `gunbc.floor_component_receipt_document` were a well-built pair โ€” schema, artifact name, path, a typed subject carrying workflow name / run id / head sha, a decoder with typed `ReceiptMemberAbsent` and `ReceiptMemberMalformed` causes, and an event-subject join refusing over three distinct mismatch arms. Read against the three questions the answer is not yes/yes/zero but **NO at the first**: nothing anywhere writes `target/floor-component-receipt.json`, no Rust carries the schema string, and `witnesses.yml` uploads four artifacts of which the receipt is not one. Their reader was the `falsifier-alert.yml` job, deleted in the 2026-08-15 CI cut. **The join is UNREACHABLE, not merely unoccupied, and that is the whole difference between retiring a dead model and deleting a live guard** โ€” a quiet guard has a producer that could fill it, this had none. The census must be run per medium, since a survivor stops being reached silently: import graph (only its own witness test), `*_test.dag` discovery (that test is a PEER of the subject, not a guard over a survivor), workflow argv, Cargo `[[bin]]`, Rust (one string literal used as a test label, not a call), and roster rows (two `gunbc.non_fold_residue` frontier rows). **The second, costlier half โ€” what the prose did while the model sat dead โ€” is its own class and is rostered separately as `unbacked_execution_claim`;** what belongs HERE is only the consequence for this rule: a dead model is not harmless while it waits, because **a good model with no consumer is still residue (ยง6), and residue that describes itself in the present tense actively manufactures false premises.** Disposition landed: both modules and their witness test deleted, the two frontier rows removed, and every surviving citation of them rewritten to the past tense with the deletion and its date recorded โ€” because a citation left naming a deleted symbol fakes a grep hit for the next reader.)", evidence: [] } -data total_at_the_level_examined_blind: RecurringFailureMode = RecurringFailureMode { identity: "total_at_the_level_examined_blind" as NonEmptyStr, authored: "**total at the level examined, blind one level down** (a judgment is exhaustive over the type it matches and says nothing about the distinction that actually decides the outcome, which lives inside one of its arms. **The pairing is the whole content: the match is exhaustive AND wrong**, and the exhaustiveness is what hides it โ€” there is no missing arm to report, no warning, nothing incomplete, so neither a reviewer reading the match nor a wildcard-free lens can see it. Specimen with a mutation receipt (gunbc.scm, 2026-08-22), **repaired on main 2026-08-25 by gunbc#8973 and kept in the past tense because the mechanism recurs while this instance does not**: a publication fold matched one outer arm carrying a JSON codec's entire refusal enum. It was total over that codec's refusal type, and the enum's arms carried three different publication remedies โ€” a reader limitation, an absent-object problem over a well-formed document, and malformed bytes. Only the third may supersede a head slot, so the collapsed form let an OLDER READER OVERWRITE A NEWER WRITER'S DOCUMENT: data loss produced by the reader's own limitation, from a match that compiled and looked complete. Executed both ways โ€” descending, four witnesses passed; collapsed, the witness driving all three remedies through the single outer arm went red; a temporary decode arm made the fold non-exhaustive; restored control passed. **None of this row's three cited symbols resolved on main, and they failed in two different ways** โ€” measured across main's whole history, not just its head. `repository_load_disposition`, the function this row said performed the collapse, has NEVER existed as code on main: it appears only in this carrier and in the projection, and it names a declaration from the CLOSED attempt gunbc#8940, whose branch was retained in history and never landed. So the row cited a function that was fabricated on the day it was written. `RepositoryDecodeImageRefusal` and `ImageRefusal` were real โ€” they landed with gunbc#8807 and gunbc#8840 and were deleted by gunbc#8965 on 2026-08-23 when the fused format was cut โ€” so those two went stale one day later, by the ordinary route a citation rots when the thing it names is removed. The two halves matter separately because they have different remedies: a fabricated name is caught by resolving symbols before writing them, a deleted one only by a resolver that reruns. Either way a reader applying this entry's own recognition rule grepped for a match that was not there. That is ยง3's stale-citation class sitting inside the authority that names it, and it is recorded here rather than quietly deleted because a specimen whose symbols cannot be resolved is the failure this row exists to teach, one level up. **What landed is stronger than descending**, and the difference is the reusable part: the remedy vocabulary was hoisted into a module that names no format โ€” `gunbc.scm.load_standing` `LoadStanding`, and each format now classifies its own refusals into it (`gunbc.scm.commit_closure_json` `closure_document_load_standing`). Descending inside one codec's enum would have closed the collapse and left the consumer still matching on a format's vocabulary; hoisting leaves the consumer no format arms to collapse, so the next format is a new classifier rather than a new way to reintroduce this defect. **THE HOISTING WAS NECESSARY AND NOT SUFFICIENT, and this sentence is the fourth citation in this row to have gone stale.** It read that the permission was derived `by one total function`, naming `standing_may_supersede_generation` โ€” a `LoadStanding -> Bool` carrying neither the slot the observation was made on nor the generation examined, so a caller could apply it to a slot the evidence never described or to a generation that had moved since it was read. gunbc#9258 deleted it and replaced the destination with `gunbc.scm.supersession` `supersede_head_slot`, where evidence is minted FROM the slot by `observe_generation` โ€” a caller cannot state which generation it examined, only examine one โ€” and the swap refuses unless the slot still holds it. The deletion left this sentence naming a declaration that no longer resolves, one day after it was written, which is the same rot this row already records twice above and is corrected here for the same reason. Second, independent specimen the same day, in the compiler itself: a census of the fourteen declared-type positions the grammar admits found five accepting a plain kernel value where a coproduct is declared โ€” the judgment is present and total at the level it examines, and never descends. **The tell is a `_` or a bare binding in the arm that carries the payload**, and the test is not whether the match is complete but whether every cause it collapses shares one remedy; the fix is to descend so a new inner arm fails to compile HERE, rather than silently inheriting whichever remedy the outer arm happened to name.)", evidence: [] } +data total_at_the_level_examined_blind: RecurringFailureMode = RecurringFailureMode { identity: "total_at_the_level_examined_blind" as NonEmptyStr, authored: "**total at the level examined, blind one level down** (a judgment is exhaustive over the type it matches and says nothing about the distinction that actually decides the outcome, which lives inside one of its arms. **The pairing is the whole content: the match is exhaustive AND wrong**, and the exhaustiveness is what hides it โ€” there is no missing arm to report, no warning, nothing incomplete, so neither a reviewer reading the match nor a wildcard-free lens can see it. Specimen with a mutation receipt (gunbc.scm, 2026-08-22), **repaired on main 2026-08-25 by gunbc#8973 and kept in the past tense because the mechanism recurs while this instance does not**: a publication fold matched one outer arm carrying a JSON codec's entire refusal enum. It was total over that codec's refusal type, and the enum's arms carried three different publication remedies โ€” a reader limitation, an absent-object problem over a well-formed document, and malformed bytes. Only the third may supersede a head slot, so the collapsed form let an OLDER READER OVERWRITE A NEWER WRITER'S DOCUMENT: data loss produced by the reader's own limitation, from a match that compiled and looked complete. Executed both ways โ€” descending, four witnesses passed; collapsed, the witness driving all three remedies through the single outer arm went red; a temporary decode arm made the fold non-exhaustive; restored control passed. **None of this row's three cited symbols resolved on main, and they failed in two different ways** โ€” measured across main's whole history, not just its head. `repository_load_disposition`, the function this row said performed the collapse, has NEVER existed as code on main: it appears only in this carrier and in the projection, and it names a declaration from the CLOSED attempt gunbc#8940, whose branch was retained in history and never landed. So the row cited a function that was fabricated on the day it was written. `RepositoryDecodeImageRefusal` and `ImageRefusal` were real โ€” they landed with gunbc#8807 and gunbc#8840 and were deleted by gunbc#8965 on 2026-08-23 when the fused format was cut โ€” so those two went stale one day later, by the ordinary route a citation rots when the thing it names is removed. The two halves matter separately because they have different remedies: a fabricated name is caught by resolving symbols before writing them, a deleted one only by a resolver that reruns. Either way a reader applying this entry's own recognition rule grepped for a match that was not there. That is ยง3's stale-citation class sitting inside the authority that names it, and it is recorded here rather than quietly deleted because a specimen whose symbols cannot be resolved is the failure this row exists to teach, one level up. **What landed is stronger than descending**, and the difference is the reusable part: the remedy vocabulary was hoisted into a module that names no format โ€” `gunbc.scm.load_standing` `LoadStanding`, and each format now classifies its own refusals into it (`gunbc.scm.commit_closure_json_v2` `closure_document_load_standing`). Descending inside one codec's enum would have closed the collapse and left the consumer still matching on a format's vocabulary; hoisting leaves the consumer no format arms to collapse, so the next format is a new classifier rather than a new way to reintroduce this defect. **THE HOISTING WAS NECESSARY AND NOT SUFFICIENT, and this sentence is the fourth citation in this row to have gone stale.** It read that the permission was derived `by one total function`, naming `standing_may_supersede_generation` โ€” a `LoadStanding -> Bool` carrying neither the slot the observation was made on nor the generation examined, so a caller could apply it to a slot the evidence never described or to a generation that had moved since it was read. gunbc#9258 deleted it and replaced the destination with `gunbc.scm.supersession` `supersede_head_slot`, where evidence is minted FROM the slot by `observe_generation` โ€” a caller cannot state which generation it examined, only examine one โ€” and the swap refuses unless the slot still holds it. The deletion left this sentence naming a declaration that no longer resolves, one day after it was written, which is the same rot this row already records twice above and is corrected here for the same reason. Second, independent specimen the same day, in the compiler itself: a census of the fourteen declared-type positions the grammar admits found five accepting a plain kernel value where a coproduct is declared โ€” the judgment is present and total at the level it examines, and never descends. **The tell is a `_` or a bare binding in the arm that carries the payload**, and the test is not whether the match is complete but whether every cause it collapses shares one remedy; the fix is to descend so a new inner arm fails to compile HERE, rather than silently inheriting whichever remedy the outer arm happened to name.)", evidence: [] } data execution_provenance_loss: RecurringFailureMode = RecurringFailureMode { identity: "execution_provenance_loss" as NonEmptyStr, authored: "**execution-provenance loss** (a reported value is consumed as though an observation produced it, while the carrier holds no proof the observation path ran. **Distinct from state-space conflation, and the repair law is what separates them**: there the carrier cannot represent the distinction and the fix is to split the states; here the carrier already has the state โ€” the consumer simply cannot tell whether it was *reached*, and the fix is to bind an execution receipt to the value. Three specimens in one night, all on main, none of them a missing variant. `known_red_held=206` is `36 held + 170 that RUNTIME-ERRORED`: the substrate splits those into distinct arms, so the states exist; what the quoted figure loses is *did the assertion run*, and an expected-red row that threw is not evidence the red is expected โ€” it is a row whose assertion never executed. A floor whose `TerminalLedgerUnrenderable` count is `0` reads identically whether the ledger checks passed or preparation refused before a ledger existed. And a `.dag` board of `E0599=23 E0308=128` reads identically whether cargo ran or normalize refused upstream of it. **The receipt that makes this worth a row rather than a caution:** for three hours after #8976 put a ยง4c-illegal annotation inside a match body, the compiler refused at strict preparation while downstream measurements kept producing valid-looking values โ€” one lane concluded a defect was closed, another concluded the same defect was still firing, both citing runs where it could not appear at all; three separate A/B comparisons reported perfect agreement, one because both arms were broken identically, one because a missing flag meant no arm executed, and one because the defect had been fixed between runs so both arms were the same bytes. Every one of those is downstream of a single fact: **a masked run and a clean run rendered identically.** Recognition rule: if a count of zero and an unreached stage produce the same output, the number is not an observation. The construction move, and it is stronger than naming known masks because it also forecloses the undiscovered ones: any stage capable of preventing a downstream observation from executing must participate in the observation carrier, so `refused before stage N` cannot inhabit the same carrier as `stage N executed with zero findings` โ€” a known mask is a hole someone found, execution provenance is what stops an unfound hole from masquerading as a measurement.)", evidence: [] } diff --git a/dag/gunbc/rung_drop.dag b/dag/gunbc/rung_drop.dag index 65364e8fec6..363a7a8609a 100644 --- a/dag/gunbc/rung_drop.dag +++ b/dag/gunbc/rung_drop.dag @@ -92,8 +92,17 @@ data fabric_evidence_gating: RungDrop = RungDrop { identity: "fabric_evidence_ga data emitted_bytes_witness_required_lane: RungDrop = RungDrop { identity: "emitted_bytes_witness_required_lane" as NonEmptyStr, subject: "Emitted-bytes fixture witnesses in a required lane", declared: "2026-09-01", standing: Standing, authored: "Witness enrollment โ€” **RUNG DROP, DECLARED (2026-09-01, gunbc#9850 witness enrollment).** PREVIOUS RUNG: none to lower โ€” this declares that the mechanically-preventable rung for the emission-follows-resolution class (the 139-row use-line shadow repaired in gunbc#9850) is held by a test OUTSIDE the required aggregate. TEMPORARY RUNG: the discriminating RED and positive controls are enrolled as `emit_import_lines_follow_resolved_binding_identity` in `v1.compiler.compiler_tests_rust`, emitted into `compiler_tests.rs` and executed by `repo_self_test_command` (`cargo test --release -p v1-compiler --lib`) in the `rust-unit-tests` job of `gunbc.witness_floor_workflow` โ€” which runs on every push and pull request but is NOT a `needs` of the required aggregate, so a regression reddens a visible job without blocking the merge. REASON: no substrate-visible surface exposes EMITTED BYTES to a `dag/test/claim` witness (the host census builtin carries diagnostics only), so the assertion is inexpressible in the required floor today; promoting `rust-unit-tests` to required was measured 2026-09-01 at 23/25 recent completions green with both failures runner-environment faults (shared-runner `$HOME`/cargo-shim class) โ€” an ~8% infra-noise rate ruled too high to make 612 tests merge-blocking fleet-wide tonight. POPULATION: the emission-follows-resolution witness matrix (String RED, structural-Bool and non-kernel controls) plus every other `compiler_tests` row asserting on emitted bytes. RESTORATION TRIGGER, named at capability grain: a required-lane capability SUFFICIENT to execute, on the real acceptance path, a fixture assertion that a named `pub use` line is ABSENT from emitted bytes for a sole-exporter structural type resolved to a host-realized kernel scalar, and PRESENT for a structural kernel carrying a connective (Bool = True | False) โ€” via either (i) `rust-unit-tests` promoted into the required aggregate after its runner-fault class is retired or priced, or (ii) a substrate-visible emitted-bytes probe surface admitted under the scaffold doctrine. A surface that can read emitted bytes but cannot discriminate those two cases does NOT satisfy this trigger." } +data direct_call_arg_seam_v2_exemption: RungDrop = RungDrop { + identity: "direct_call_arg_seam_v2_exemption" as NonEmptyStr, + subject: "Direct-call argument TYPE-COMPAT judgment inside v2.* modules (one of two arms; inhabitance still runs)", + declared: "2026-09-01", + standing: Standing, + authored: "**ONE OF THE TWO DIRECT-CALL ARGUMENT JUDGMENTS IS SWITCHED OFF FOR EVERY `v2.*` MODULE, AND THIS ROW DECLARES THAT RUNG (2026-09-01).** The suppressed arm is `arg_compat_diags`; the inhabitance arm beside it is ungated and still runs there. That split is measured below, not assumed, and the loose reading -- that argument checking is off in `v2.*` -- is what this row exists to stop being repeated. `v1.compiler.infer` `module_skips_direct_call_arg_check` returns true for every module whose declared name begins with `v2.`, so at a direct call inside such a module the argument-position TYPE-COMPAT judgment does not run. It is ONE of two arms and the measurement below says which: the ungated inhabitance arm still runs there, so this is a narrowed judgment, not an absent one. The predicate is not new and its cost is not disputed: `gunbc.doc_graph_roots` already names it as the one in-tree violation of the no-escape-hatch clause, in its own words that the compiler being bootstrapped is authored under weaker checks than ordinary source. What has never existed is this row. PREVIOUS RUNG AT THIS ROW'S OWN SUBJECT GRAIN: NONE STOOD, and saying otherwise would be the cross-path inflation DESIGN 4b(1) forbids (codex review 58154, which caught it here). The subject is the compat arm INSIDE `v2.*`. That arm has never executed there -- the exemption predates this row -- so there is no rung for this row to have lowered and none is claimed. What the fixture establishes is a DIFFERENT path: that the suppressed check operates OUTSIDE the exemption, on the same tree and the same binary, which is what makes the exemption a real loss of an available guarantee rather than a check nobody has. Those two facts must not be averaged: `mechanically preventable outside v2.*` and `never executed inside v2.*` are separate paths, and a class's rung is the MINIMUM across its in-scope paths. This row therefore declares a STANDING ABSENCE at its subject grain rather than a regression, and it is filed as a drop because the obligation 4b(3) attaches -- population, reason, and a trigger that can retire it -- is the obligation an undeclared permanent exemption was escaping. TEMPORARY RUNG for the compat arm inside `v2.*`: mitigatable where the emitted-Rust self-host closure covers the module, because a wrong argument surviving acceptance becomes a rustc type error in the emitted crate -- refused late, in the wrong compiler, in the wrong phase -- and UNGUARDED on the source-acceptance path for every `v2.*` module that closure does not reach. Those are two paths and the row reports the MINIMUM, so the temporary rung for this subject is UNGUARDED, not mitigatable; the mitigated path is named because it bounds where the loss is observable at all, never to raise the reported rung. The ungated inhabitance arm keeps its rung throughout and is NOT part of this drop. REASON: the exemption's stated justification is representation-gap false positives at the argument seam โ€” the same four classes the conformance lane grounds (brand aliases, optionality's two representations, anonymous record literals, expansion depth) โ€” plus an unlocated historical claim of 104 TypeMismatch false positives that two audits have failed to find a receipt for. Its deletion is gunbc#8924, which is COMPLETE and MEASURED and CLOSED, held because its one missing precondition is a resolve-layer seam whose repair was DECLINED at the owning layer on 2026-08-22 with no owner and no schedule. THE POPULATION IS RESTATED HERE AND IT IS NOT THE ONE THE HOLD WAS PRICED AGAINST. The reopening condition recorded on `gunbc.doc_graph_roots` bounds the blast radius at 9 declarations under `src/v2/extdeps/formatters/`. The predicate's actual population is EVERY MODULE WHOSE DECLARED NAME BEGINS WITH `v2.` โ€” the instrument is the predicate itself, re-derivable by matching `^module v2.` against the corpus's module declarations, and the count is deliberately not transcribed here. That population includes the SELF-HOST EMISSION CLOSURE. WHAT THE EXEMPTION ACTUALLY SUPPRESSES, MEASURED AT DIAGNOSTIC IDENTITY RATHER THAN ARGUED FROM THE PREDICATE. The instrument is one fixture authored twice, byte-identical but for its module line and a renaming of every declaration so nothing resolves across the pair, run through a gunbc built from this tree. Three arms each: a local alias parameter (`type Wrapped = FreeMonoid` declared in the calling module), a cross-module declaration-path parameter (`v2.std.text.String`), and an `Int` parameter as control, each receiving a kernel `String` actual. NON-`v2.` MODULE: the local-alias arm is ADMITTED with no diagnostic; the declaration-path arm REFUSES with `value does not inhabit its declared type at the direct call argument`, declared `Node(std.algebra.FreeMonoid)` produced `Primitive(String)`; the control REFUSES TWICE, once with `type mismatch: expected Primitive(Int), got Primitive(String)` and once with the inhabitance message. `v2.*` MODULE: the local-alias arm is ADMITTED; the declaration-path arm REFUSES with the SAME inhabitance message; the control refuses with the inhabitance message ONLY. EXACTLY ONE DIAGNOSTIC IDENTITY DISAPPEARS across the pair -- the `type mismatch` row -- and every other identity is preserved. That is the drop, measured: this exemption suppresses `arg_compat_diags` and NOTHING ELSE. THE SCOPE OF THE LOSS IS THEREFORE NARROWER THAN THE PREDICATE NAME SUGGESTS, and stating it narrowly is the point of measuring it. `v1.compiler.infer` computes `arg_compat_diags` under this gate and calls `direct_call_argument_inhabitance_diags` immediately after with NO gate, both feeding one `concat`, so the INHABITANCE judgment runs inside `v2.*` exactly as it runs everywhere else. A reader who takes the argument judgment to be off in `v2.*` -- as the first draft of this row did -- will misattribute every surviving refusal and every surviving admission. CONSEQUENTLY THIS ROW CLAIMS NO SELF-HOST HARM AND NAMES NONE. The specimen anyone reaches for first is `src/v2/std/integer.dag` calling `v2.std.text` `string_head`, whose emitted Rust rustc refuses; that call is NOT hidden by this exemption, because the surviving inhabitance arm is the one that judges its shape and the fixture shows the compat arm would not have refused it either. That shape belongs to a DIFFERENT and already-standing declaration, `text_boundary_identity_wall`. Citing it here would be an unbacked execution claim. THE CLAIM IS NOT THAT THE BLAST RADIUS NOW EXCEEDS 9. It is that THE SUBJECT CHANGED and the old bound no longer describes what is being bought, which is a reason to re-measure; a declared drop whose population is measured against a subject it no longer covers is the drop lying about its own size. ONE CONSEQUENCE FOR ANY READER OF A GREEN `v2.*` CALL SITE, stated precisely because the loose version of it is the easiest false inference available here and this row asserted the loose version first: a `v2.*` call site accepted WITHOUT a compat diagnostic says nothing, because that arm cannot speak there; a `v2.*` call site accepted with no INHABITANCE diagnostic says exactly what it says everywhere else, because that arm is ungated. A discriminating red for the SUPPRESSED arm is therefore only authorable in a non-`v2.` module -- not because the wall is absent in `v2.*`, but because only one of its two arms is. RESTORATION TRIGGER, named at capability grain: the resolve-layer seam capability that makes `arg_compat_diags` RUNNABLE on `v2.*` modules -- the argument-position conformance relation grounding the representation gaps that motivated the exemption (brand aliases, optionality's two representations, anonymous record literals, expansion depth), so that the compat arm refuses genuinely wrong argument bindings inside `v2.*` without fabricating a refusal against those four grounded classes. That capability is SUFFICIENT only under a two-direction A/B measured the way this row's own population was: the twin fixture re-run, with the `type mismatch` identity PRESENT on both the non-`v2.` and the `v2.*` side, and the corpus's existing `v2.*` call sites still ADMITTED. gunbc#8924 MERGING IS NOT THIS TRIGGER, and neither is any measurement that only re-prices the hold; the trigger is the seam being repaired at its owning layer, which was DECLINED on 2026-08-22 and is UNOWNED as of this declaration -- a trigger nobody is holding is still a trigger, and a reader must be able to see that nobody is holding it." +} + data rung_drop_roster: List = [ emitted_bytes_witness_required_lane, + direct_call_arg_seam_v2_exemption, floor_cut, measurement_bankruptcy, regen_producer, diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index 33c4a1f4a2b..50afcaf6e34 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -247,7 +247,7 @@ fn unresolved_identities(closure: CommitClosure) -> List { // have are checked separately against the commit list. Folding the root into this would have forced // the repository to invent one. // UNCONTAINED TARGETS ACCUMULATE BY PREPEND AND ARE REVERSED ONCE, the same idiom this stack applies -// in commit_closure_json encode_step and in repository_envelope's commit fold. It is restated at +// in commit_closure_json_v2 encode_step and in repository_envelope's commit fold. It is restated at // the third site because the first two were not enough: this module documented the rule and then // reintroduced the shape it forbids, which is the ยง6 point exactly -- fixing a cost shape where a // review names it is not the same as not having the defect, and the sweep has to cover every diff --git a/dag/gunbc/scm/commit_closure_json.dag b/dag/gunbc/scm/commit_closure_json_v2.dag similarity index 96% rename from dag/gunbc/scm/commit_closure_json.dag rename to dag/gunbc/scm/commit_closure_json_v2.dag index c2fff6aacab..60018965626 100644 --- a/dag/gunbc/scm/commit_closure_json.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -1,16 +1,28 @@ -module gunbc.scm.commit_closure_json - -// THE MODULE NAME CARRIED A VERSION AND THE DOCUMENT CARRIED A DIFFERENT ONE, which is a meaning -// fork in the place least able to afford it. It was `commit_closure_json_v2`, a suffix that named -// the second CODEC generation -- the predecessor that encoded the root positionally, since deleted. -// With that sibling gone the suffix distinguished nothing, and once the schema grew a member and the -// tag became `โ€ฆ-v3`, one authority was announcing v2 in its name and v3 in its bytes. A reader has no -// way to know which of the two is the contract. +module gunbc.scm.commit_closure_json_v2 + +// THE MODULE NAME CARRIES A VERSION AND THE DOCUMENT CARRIES A DIFFERENT ONE. `_v2` named the second +// CODEC GENERATION -- the predecessor that encoded the root positionally, since deleted -- so with +// that sibling gone the suffix distinguishes nothing, and once the schema grew a member and the tag +// became `โ€ฆ-v3`, this module announces v2 in its name and v3 in its bytes. THE TAG IS THE CONTRACT +// AND THE NAME IS A FOSSIL: `closure_document_format_tag` is the value the encoder emits and the +// decoder compares, and it is the only thing that decides which schema this build writes. // -// So the module names the CODEC and the tag names the VERSION, one fact each. There is exactly one -// place to look for which schema this build writes, and it is a value the encoder actually emits -// rather than a spelling in a path. Named by the designated SCM reviewer while tracing whether -// retired version names still fork meaning; they did. +// THE RENAME IS DEFERRED, AND THE REASON IS AN EXTERNALITY RATHER THAN EFFORT. It was authored and +// reverted after measuring what it costs. A module rename re-targets every binding that names it, +// which the required namespace-wave-admission phase classifies `TargetChanged` -- not auto-admitted, +// so each needs a row in NAMESPACE_TRANSITION_ADMISSIONS. This rename produced 41. That roster's own +// header records what happened the last time rows sat on main: `stale_admissions` is computed PER +// RUN, so rows on main are inherited by every open pull request, and a change that touches no +// namespace can never match them -- the phase then refuses every unrelated lane until the rows are +// dissolved. Landing 41 of them to fix a name would spend every other lane's CI to buy this one's +// tidiness, which is the externalized cost DESIGN section 5 names, and it would do it inside a pull +// request whose subject is the object model. +// +// So the fork is REPORTED rather than carried silently, and it is not repaired by this comment +// existing: a reader who trusts the module path over the emitted tag is still wrong. What this note +// buys is that the two are not equally authoritative-looking. dissolve-on: a rename landed as its +// own change, with its admission rows authored and deleted in the same wave, at a moment when the +// open-PR population can absorb them -- not folded into an unrelated subject. import std.types { Bool, Int, List, String, list_length } diff --git a/dag/gunbc/scm/json_member.dag b/dag/gunbc/scm/json_member.dag index 229b79a363e..700a585bceb 100644 --- a/dag/gunbc/scm/json_member.dag +++ b/dag/gunbc/scm/json_member.dag @@ -1,7 +1,7 @@ module gunbc.scm.json_member // READING A NAMED MEMBER OFF A JSON OBJECT IS ONE OPERATION WITH ONE FAILURE POPULATION, and this -// module owns both. It was previously homed inside gunbc.scm.commit_closure_json with its failure +// module owns both. It was previously homed inside gunbc.scm.commit_closure_json_v2 with its failure // typed as THAT module's ClosureDocRefusal, which is the layer inversion DESIGN section 3 names: a // domain-neutral facility carrying a domain's vocabulary. The measured cost was not stylistic. Every // repository-envelope member read -- `checked_out`, `identity_rule`, `root`, `message` -- had to diff --git a/dag/gunbc/scm/log.dag b/dag/gunbc/scm/log.dag index 92e257f16e2..955e8a98cf1 100644 --- a/dag/gunbc/scm/log.dag +++ b/dag/gunbc/scm/log.dag @@ -54,7 +54,7 @@ type LoggedCommitStanding // recency, because the allocator watermark is uniqueness state rather than chronology. type CommitLogRow { reference: RepositoryCommitRef - root: ObjectId + root: SemanticNodeObjectRef message: String standing: LoggedCommitStanding } diff --git a/dag/gunbc/scm/object_table_json.dag b/dag/gunbc/scm/object_table_json.dag index 4af5fc0af5c..7f95384a2f2 100644 --- a/dag/gunbc/scm/object_table_json.dag +++ b/dag/gunbc/scm/object_table_json.dag @@ -1,7 +1,7 @@ module gunbc.scm.object_table_json // DECODING THE OBJECT TABLE IS ONE OPERATION WITH ONE FAILURE POPULATION, and this module owns both. -// It was previously homed inside gunbc.scm.commit_closure_json with its failures typed as THAT +// It was previously homed inside gunbc.scm.commit_closure_json_v2 with its failures typed as THAT // module's ClosureDocRefusal, so six object-population causes sat in a coproduct that also answers // for envelope format and closure-root facts. A consumer matching that type had to handle causes // from three different owners, and no reader could tell from the type which owner produced which. diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 7bf446c5899..f989b2a0aa3 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -1,8 +1,8 @@ module gunbc.scm.repository_envelope -// THE REPOSITORY ENVELOPE: the layer that gunbc.scm.commit_closure_json deliberately is not. +// THE REPOSITORY ENVELOPE: the layer that gunbc.scm.commit_closure_json_v2 deliberately is not. // -// commit_closure_json holds ONE ROOT AND ITS CLOSURE. A repository holds facts that carrier cannot express, +// commit_closure_json_v2 holds ONE ROOT AND ITS CLOSURE. A repository holds facts that carrier cannot express, // and each one is a reason this module exists rather than a field bolted onto that one: // // - AN EMPTY INITIALIZED STATE. `init` produces a repository with no commits and no root at all. @@ -94,7 +94,7 @@ import gunbc.scm.object_store { find_node_record, } import gunbc.scm.commit_closure { uncontained_targets } -import gunbc.scm.commit_closure_json { +import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, DecodeAcc, DecodePositions, @@ -133,7 +133,7 @@ import gunbc.scm.load_standing { // -- which is canonical because the digests are already content-derived. That is a real change to // encode_object_table rather than a flag, so it is a separate slice, not a footnote. // v2, FOR TWO CHANGES THAT ARE BOTH SCHEMA CHANGES BY THIS STACK'S OWN RULE. The embedded object -// table is gunbc.scm.commit_closure_json's, which now admits authored-source entries, so a v1 +// table is gunbc.scm.commit_closure_json_v2's, which now admits authored-source entries, so a v1 // reader refuses documents this writer can produce. And a commit's `root` is now RESOLVED against // that table at decode -- a root the table does not carry, or carries as a file, refuses here rather // than being admitted and noticed later -- so the set of documents that decode is different in both @@ -266,7 +266,7 @@ type RepositoryDecodeRefusal // THE CONTEXT VOCABULARY IS THIS LAYER'S, THE MEMBERSHIP MECHANISM IS SHARED. gunbc.scm.json_member owns // `unexpected_member_key`, which answers a context-free question -- which key is not in this set -- -// and each layer names its own shapes. Adding `RepositoryEnvelopeMembers` to commit_closure_json's context +// and each layer names its own shapes. Adding `RepositoryEnvelopeMembers` to commit_closure_json_v2's context // coproduct would have made the lower carrier enumerate the shapes of a layer above it, which is the // inversion DESIGN section 3 names; duplicating the fold here instead would have been a second // authority for one rule. Neither is necessary: the mechanism generalizes, the vocabulary does not. @@ -700,7 +700,7 @@ fn unresolved_checkout_selection(commits: List, selected: Repo // COMMITS ACCUMULATE BY PREPEND AND ARE REVERSED ONCE AT THE CONSUMER. concat-at-end inside a fold // copies the whole accumulator per commit, which is the quadratic shape DESIGN section 6 says is -// always fixed regardless of the realized n. This stack removed that shape from commit_closure_json and from +// always fixed regardless of the realized n. This stack removed that shape from commit_closure_json_v2 and from // object_store.dag and then reintroduced it here in the same diff -- caught by review, and the point // worth recording is that fixing a cost shape at the sites a review names is not the same as not // having the defect: the sweep has to be over every accumulator the change touches, or the rule is @@ -1207,7 +1207,7 @@ fn decode_repository_checkout( // THE SECOND FORMAT'S CLASSIFIER, AND IT WAS MISSING RATHER THAN UNNECESSARY. // -// gunbc.scm.commit_closure_json and gunbc.scm.object_table_json each classify their own refusals +// gunbc.scm.commit_closure_json_v2 and gunbc.scm.object_table_json each classify their own refusals // into the format-agnostic vocabulary; this document format did not, so any consumer deciding what to // do with a refused repository load had to reach into RepositoryDecodeRefusal and assign a standing // itself -- one format's error enum answering a format-agnostic question, which is the placement diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index 34afc5a804b..9933f9bc951 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -140,7 +140,7 @@ fn observe_generation( // destination's whole producer side, not any one defect site. Measured on the live tree rather than // recalled, by enumerating every declaration that returns a `LoadStanding`. // -// PRODUCERS, TOTAL: two, both in `gunbc.scm.commit_closure_json`. +// PRODUCERS, TOTAL: two, both in `gunbc.scm.commit_closure_json_v2`. // `closure_document_load_standing` classifies one format's refusals. It descends everywhere a // payload could hide a second distinction -- through `MemberReadRefusal`'s four modes and // `TargetReferenceRefusal`'s four -- and is the ONLY declaration in the tree that can return diff --git a/dag/test/claim/scm/scm_commit_closure_json_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag similarity index 99% rename from dag/test/claim/scm/scm_commit_closure_json_witness_test.dag rename to dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 22eb219eafd..64385c4fb1c 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -1,11 +1,11 @@ -module test.claim.scm_commit_closure_json_witness +module test.claim.scm_commit_closure_json_v2_witness // The durable repository image, executed. The claim under test is that a store survives the round // trip to text and back AS THE SAME PROGRAM, and that every way the text can lie REFUSES rather than // producing a plausible store. // // The identity claims here are the load-bearing ones. The image carries NO object identities -- see -// gunbc.scm.commit_closure_json's header for why -- so what these witnesses establish is that identity survives +// gunbc.scm.commit_closure_json_v2's header for why -- so what these witnesses establish is that identity survives // anyway, because it is re-derived from structure on load. A round trip that merely produced "some // store" would satisfy a weaker test; comparing the reconstructed PROGRAM through checkout is what // makes it a claim about meaning rather than about bytes. @@ -118,7 +118,7 @@ import gunbc.scm.object_table_json { ObjectTableEdgeTargetIsAuthoredSource, ObjectTableObjectCollision, } -import gunbc.scm.commit_closure_json { +import gunbc.scm.commit_closure_json_v2 { ClosureDocObjectTableRefused, ClosureDocLoadOutcome, ClosureDocLoaded, diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index c3c65295540..f59bceaea09 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -50,7 +50,7 @@ import gunbc.scm.target_reference { TargetReferenceMemberReadRefused, decode_target_reference, } -import gunbc.scm.commit_closure_json { +import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, ClosureDocIncompleteWithoutAdmission, ClosureDocEncodeRefusal, diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index 3127e0bdaf7..dff1d6292e8 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -49,7 +49,7 @@ import gunbc.scm.repository_envelope { RepositoryCommitRootReferenceUnresolved, repository_envelope_load_standing, } -import gunbc.scm.commit_closure_json { +import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, ClosureDocFormatUnrecognized, ClosureDocObjectTableRefused, ClosureDocMemberReadRefused, ClosureDocUnexpectedMember, ClosureDocEnvelopeMembers, diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 0d8de82c5c1..914133eff77 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -673,7 +673,7 @@ fn merge_witness_source_fixture() -> Fixture { // supplies is stored through store_node and is a semantic node by construction. The only way a role // value names a file is for the TARGET ROOT's own child edge to point at one, and the only producer // of that shape is the decoder resolving an edge position into an authored entry. So that claim lives -// beside the document builder, in test.claim.scm_commit_closure_json_witness, where the input can +// beside the document builder, in test.claim.scm_commit_closure_json_v2_witness, where the input can // actually be constructed -- rather than here, where it would need a constructor the model correctly // does not offer. fn merge_refusal_tag(o: MergeOutcome) -> String { diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index a776e3fe3ce..39e162deae6 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -1,6 +1,6 @@ module test.claim.scm_repository_envelope_witness -// The repository envelope, executed. What is under test is the layer commit_closure_json deliberately is not: +// The repository envelope, executed. What is under test is the layer commit_closure_json_v2 deliberately is not: // an empty initialized repository with NO root, several commits over ONE shared node population, and // a checked-out selection that may be absent but may never dangle. // @@ -270,7 +270,7 @@ test fn scm_env_the_root_signature_distinguishes_order() -> Bool { // // This is the THIRD time this stack has shipped an observation structurally incapable of producing // its own negative, and the second time the identical defect was fixed one layer down and then -// reintroduced one layer up -- commit_closure_json's sharing claim had exactly this shape and exactly this +// reintroduced one layer up -- commit_closure_json_v2's sharing claim had exactly this shape and exactly this // repair. Recorded here rather than silently corrected, because the pattern is the finding: a claim // whose subject is an encoding must observe the ENCODED form, and deduplicating consumers make any // downstream count agree with itself. diff --git a/docs/design-ledgers.md b/docs/design-ledgers.md index 1f5d6a45011..edc02cf8587 100644 --- a/docs/design-ledgers.md +++ b/docs/design-ledgers.md @@ -52,6 +52,10 @@ Each row declares a safety guarantee that was lowered: what stood before, what s Witness enrollment โ€” **RUNG DROP, DECLARED (2026-09-01, gunbc#9850 witness enrollment).** PREVIOUS RUNG: none to lower โ€” this declares that the mechanically-preventable rung for the emission-follows-resolution class (the 139-row use-line shadow repaired in gunbc#9850) is held by a test OUTSIDE the required aggregate. TEMPORARY RUNG: the discriminating RED and positive controls are enrolled as `emit_import_lines_follow_resolved_binding_identity` in `v1.compiler.compiler_tests_rust`, emitted into `compiler_tests.rs` and executed by `repo_self_test_command` (`cargo test --release -p v1-compiler --lib`) in the `rust-unit-tests` job of `gunbc.witness_floor_workflow` โ€” which runs on every push and pull request but is NOT a `needs` of the required aggregate, so a regression reddens a visible job without blocking the merge. REASON: no substrate-visible surface exposes EMITTED BYTES to a `dag/test/claim` witness (the host census builtin carries diagnostics only), so the assertion is inexpressible in the required floor today; promoting `rust-unit-tests` to required was measured 2026-09-01 at 23/25 recent completions green with both failures runner-environment faults (shared-runner `$HOME`/cargo-shim class) โ€” an ~8% infra-noise rate ruled too high to make 612 tests merge-blocking fleet-wide tonight. POPULATION: the emission-follows-resolution witness matrix (String RED, structural-Bool and non-kernel controls) plus every other `compiler_tests` row asserting on emitted bytes. RESTORATION TRIGGER, named at capability grain: a required-lane capability SUFFICIENT to execute, on the real acceptance path, a fixture assertion that a named `pub use` line is ABSENT from emitted bytes for a sole-exporter structural type resolved to a host-realized kernel scalar, and PRESENT for a structural kernel carrying a connective (Bool = True | False) โ€” via either (i) `rust-unit-tests` promoted into the required aggregate after its runner-fault class is retired or priced, or (ii) a substrate-visible emitted-bytes probe surface admitted under the scaffold doctrine. A surface that can read emitted bytes but cannot discriminate those two cases does NOT satisfy this trigger. +### Direct-call argument TYPE-COMPAT judgment inside v2.* modules (one of two arms; inhabitance still runs) โ€” declared 2026-09-01 + +**ONE OF THE TWO DIRECT-CALL ARGUMENT JUDGMENTS IS SWITCHED OFF FOR EVERY `v2.*` MODULE, AND THIS ROW DECLARES THAT RUNG (2026-09-01).** The suppressed arm is `arg_compat_diags`; the inhabitance arm beside it is ungated and still runs there. That split is measured below, not assumed, and the loose reading -- that argument checking is off in `v2.*` -- is what this row exists to stop being repeated. `v1.compiler.infer` `module_skips_direct_call_arg_check` returns true for every module whose declared name begins with `v2.`, so at a direct call inside such a module the argument-position TYPE-COMPAT judgment does not run. It is ONE of two arms and the measurement below says which: the ungated inhabitance arm still runs there, so this is a narrowed judgment, not an absent one. The predicate is not new and its cost is not disputed: `gunbc.doc_graph_roots` already names it as the one in-tree violation of the no-escape-hatch clause, in its own words that the compiler being bootstrapped is authored under weaker checks than ordinary source. What has never existed is this row. PREVIOUS RUNG AT THIS ROW'S OWN SUBJECT GRAIN: NONE STOOD, and saying otherwise would be the cross-path inflation DESIGN 4b(1) forbids (codex review 58154, which caught it here). The subject is the compat arm INSIDE `v2.*`. That arm has never executed there -- the exemption predates this row -- so there is no rung for this row to have lowered and none is claimed. What the fixture establishes is a DIFFERENT path: that the suppressed check operates OUTSIDE the exemption, on the same tree and the same binary, which is what makes the exemption a real loss of an available guarantee rather than a check nobody has. Those two facts must not be averaged: `mechanically preventable outside v2.*` and `never executed inside v2.*` are separate paths, and a class's rung is the MINIMUM across its in-scope paths. This row therefore declares a STANDING ABSENCE at its subject grain rather than a regression, and it is filed as a drop because the obligation 4b(3) attaches -- population, reason, and a trigger that can retire it -- is the obligation an undeclared permanent exemption was escaping. TEMPORARY RUNG for the compat arm inside `v2.*`: mitigatable where the emitted-Rust self-host closure covers the module, because a wrong argument surviving acceptance becomes a rustc type error in the emitted crate -- refused late, in the wrong compiler, in the wrong phase -- and UNGUARDED on the source-acceptance path for every `v2.*` module that closure does not reach. Those are two paths and the row reports the MINIMUM, so the temporary rung for this subject is UNGUARDED, not mitigatable; the mitigated path is named because it bounds where the loss is observable at all, never to raise the reported rung. The ungated inhabitance arm keeps its rung throughout and is NOT part of this drop. REASON: the exemption's stated justification is representation-gap false positives at the argument seam โ€” the same four classes the conformance lane grounds (brand aliases, optionality's two representations, anonymous record literals, expansion depth) โ€” plus an unlocated historical claim of 104 TypeMismatch false positives that two audits have failed to find a receipt for. Its deletion is gunbc#8924, which is COMPLETE and MEASURED and CLOSED, held because its one missing precondition is a resolve-layer seam whose repair was DECLINED at the owning layer on 2026-08-22 with no owner and no schedule. THE POPULATION IS RESTATED HERE AND IT IS NOT THE ONE THE HOLD WAS PRICED AGAINST. The reopening condition recorded on `gunbc.doc_graph_roots` bounds the blast radius at 9 declarations under `src/v2/extdeps/formatters/`. The predicate's actual population is EVERY MODULE WHOSE DECLARED NAME BEGINS WITH `v2.` โ€” the instrument is the predicate itself, re-derivable by matching `^module v2.` against the corpus's module declarations, and the count is deliberately not transcribed here. That population includes the SELF-HOST EMISSION CLOSURE. WHAT THE EXEMPTION ACTUALLY SUPPRESSES, MEASURED AT DIAGNOSTIC IDENTITY RATHER THAN ARGUED FROM THE PREDICATE. The instrument is one fixture authored twice, byte-identical but for its module line and a renaming of every declaration so nothing resolves across the pair, run through a gunbc built from this tree. Three arms each: a local alias parameter (`type Wrapped = FreeMonoid` declared in the calling module), a cross-module declaration-path parameter (`v2.std.text.String`), and an `Int` parameter as control, each receiving a kernel `String` actual. NON-`v2.` MODULE: the local-alias arm is ADMITTED with no diagnostic; the declaration-path arm REFUSES with `value does not inhabit its declared type at the direct call argument`, declared `Node(std.algebra.FreeMonoid)` produced `Primitive(String)`; the control REFUSES TWICE, once with `type mismatch: expected Primitive(Int), got Primitive(String)` and once with the inhabitance message. `v2.*` MODULE: the local-alias arm is ADMITTED; the declaration-path arm REFUSES with the SAME inhabitance message; the control refuses with the inhabitance message ONLY. EXACTLY ONE DIAGNOSTIC IDENTITY DISAPPEARS across the pair -- the `type mismatch` row -- and every other identity is preserved. That is the drop, measured: this exemption suppresses `arg_compat_diags` and NOTHING ELSE. THE SCOPE OF THE LOSS IS THEREFORE NARROWER THAN THE PREDICATE NAME SUGGESTS, and stating it narrowly is the point of measuring it. `v1.compiler.infer` computes `arg_compat_diags` under this gate and calls `direct_call_argument_inhabitance_diags` immediately after with NO gate, both feeding one `concat`, so the INHABITANCE judgment runs inside `v2.*` exactly as it runs everywhere else. A reader who takes the argument judgment to be off in `v2.*` -- as the first draft of this row did -- will misattribute every surviving refusal and every surviving admission. CONSEQUENTLY THIS ROW CLAIMS NO SELF-HOST HARM AND NAMES NONE. The specimen anyone reaches for first is `src/v2/std/integer.dag` calling `v2.std.text` `string_head`, whose emitted Rust rustc refuses; that call is NOT hidden by this exemption, because the surviving inhabitance arm is the one that judges its shape and the fixture shows the compat arm would not have refused it either. That shape belongs to a DIFFERENT and already-standing declaration, `text_boundary_identity_wall`. Citing it here would be an unbacked execution claim. THE CLAIM IS NOT THAT THE BLAST RADIUS NOW EXCEEDS 9. It is that THE SUBJECT CHANGED and the old bound no longer describes what is being bought, which is a reason to re-measure; a declared drop whose population is measured against a subject it no longer covers is the drop lying about its own size. ONE CONSEQUENCE FOR ANY READER OF A GREEN `v2.*` CALL SITE, stated precisely because the loose version of it is the easiest false inference available here and this row asserted the loose version first: a `v2.*` call site accepted WITHOUT a compat diagnostic says nothing, because that arm cannot speak there; a `v2.*` call site accepted with no INHABITANCE diagnostic says exactly what it says everywhere else, because that arm is ungated. A discriminating red for the SUPPRESSED arm is therefore only authorable in a non-`v2.` module -- not because the wall is absent in `v2.*`, but because only one of its two arms is. RESTORATION TRIGGER, named at capability grain: the resolve-layer seam capability that makes `arg_compat_diags` RUNNABLE on `v2.*` modules -- the argument-position conformance relation grounding the representation gaps that motivated the exemption (brand aliases, optionality's two representations, anonymous record literals, expansion depth), so that the compat arm refuses genuinely wrong argument bindings inside `v2.*` without fabricating a refusal against those four grounded classes. That capability is SUFFICIENT only under a two-direction A/B measured the way this row's own population was: the twin fixture re-run, with the `type mismatch` identity PRESENT on both the non-`v2.` and the `v2.*` side, and the corpus's existing `v2.*` call sites still ADMITTED. gunbc#8924 MERGING IS NOT THIS TRIGGER, and neither is any measurement that only re-prices the hold; the trigger is the seam being repaired at its owning layer, which was DECLINED on 2026-08-22 and is UNOWNED as of this declaration -- a trigger nobody is holding is still a trigger, and a reader must be able to see that nobody is holding it. + ### CI required-run composition โ€” declared 2026-08-15 CI โ€” **RUNG DROP, DECLARED (2026-08-15, the floor cut).** WHAT WAS HERE: one composed floor pass, `claim_executor` with a `--plan-entry` naming `src/v2/workflow/ci_floor_plan.dag`, a v2 scheduler deciding batches from `gunbc.ci_spec`, a compile-clean gate ordered ahead of everything else, per-PR discovery over `CiSpec.discovery_scan_dirs`, and a 4-hourly `affected-set-falsifier` cadence carrying the whole-tree cold controls. **ALL OF IT IS DELETED** โ€” the workflows (`ci.yml`, `falsifier.yml`, `falsifier-alert.yml`), their `.dag` authorities, and ~30 witness modules. This paragraph previously recited that invocation in the present tense; it was false the moment the cut landed, and a knowingly-false recital in the canonical authority is premise contamination โ€” every session reading it plans against a command that does not exist. WHAT RUNS NOW: one emission, `gunbc.witness_floor_workflow` โ†’ `.github/workflows/witnesses.yml`, invoking our own binary once PER LANE โ€” `claim_executor --required-ci --source-root dag --source-root src/v2 --required-lane build` and `... --required-lane witnesses`, in TWO PARALLEL JOBS (the 2026-08-25 split described below; the invocation named `--required-floor` until the 2026-08-20 consolidation, also described below, and that flag still exists and still runs the fold alone, it is simply no longer what CI calls) โ€” whose floor phase folds every discovered witness through one prepared subject via `v1_compiler.cli_run` `run_required_floor`, whose admission, cost-line and preparation-safety policy is owned by `v2.workflow.required_floor` (this clause named that module as the symbol home until 2026-08-26; the module is real and the symbol does not resolve there, which is exactly the ยง3 defect the `--required-cited-symbol` census would have refused before its 2026-08-23 drop). No plan entry, no plan function, no batch id, no worker role, no selection flag: the fold has no such concepts to configure โ€” which PRESERVES the 2026-08-13 operator directive that no SELECTION shrinks the roster, by construction rather than by a flag someone must remember to set. **That directive is about selection, and an earlier revision of this clause stated it as `the whole discovered roster runs unshrunk`, which is false of the DISCOVERED roster and true only of the ROUTED one.** Measured on main run `32553487573` (`967b5bc1b92`, 2026-08-22T05:06Z): `offered=11812 routed=10439 declined_long=543 declined_live=830`. 1373 discovered sites โ€” 11.6% โ€” are declined by HOME POLICY before the fold sees them, so `planned` is the routed roster and never the discovered one. The floor's own line is honest about this (`every discovered site is exactly one of these`); only this document overclaimed, and the overclaim is the load-bearing kind, because a reader who takes `the whole discovered roster runs` literally will conclude that authoring a witness is sufficient for it to execute. It is not: a witness under a long or live home is discovered, counted, and never run. The decline mechanism is not a selection flag and the directive is not violated by it โ€” the two are different questions, and conflating them is what made one true sentence and one false sentence read as the same claim. That directive's own carrier (`corpus_selection_off_note`) died with the floor; `gunbc.ci_spec` retains the history and now points here. **THE FOLD NOW EXECUTES, measured 2026-08-19.** Green on main, the latest measured `32553487573` (`967b5bc1b92`, 2026-08-22T05:06Z): `planned=10439 executed=10439 terminal=10439 passed=10132 known_red_held=206 failed=0 stale_quarantine=0 route_gap_held=101 over_cost_line_diagnostic=35`, ~30 min wall. The counts previously cited here were run `32515441229` of 2026-08-21 (`offered=11615 routed=10253 โ€ฆ passed=9946`) and had gone stale by 197 offered sites in under twelve hours; they are replaced rather than annotated, because two count sets in one clause is two accounts of one fact. That this clause has now been re-pointed twice in three days is itself the measurement: a transcribed receipt line is a *positional* citation of a run's output โ€” ยง3's rule reaches it, since no edit here invalidates it and it rots anyway โ€” so the standing question is now DECIDED (operator ruling, 2026-08-24): **name the instrument, never transcribe its output.** A measurement is cited by naming the producer that re-derives it โ€” the run, the flag, the committed script โ€” and never by copying its numbers into prose, exactly as ยง3 requires a citation to name a symbol rather than a line, and for the same reason: a transcribed number is unreachable from the thing that owns it, so it rots without anyone touching either end. The counters above are retained under that rule as a declared exception with their producing run named, because this clause's subject IS the rung drop and a drop is unreadable without the magnitude it dropped by; every other transcription in the corpus is debt. The ruling was priced, not preferred: the parallel measurement corpus it governs was BANKRUPTED the same day โ€” `docs/probes/` deleted whole, 195 files and 50,601 lines, boards and captures and instruments alike, leaving NO `.sh` or `.py` anywhere in the repository outside `.githooks` โ€” after a board asserting a compiler mechanism as a live defect was found to have merged EIGHT SECONDS after the commit that fixed it, and to have named the emitter carrier when the actual repair was two lines in the model. A lane had already selected that mechanism as its next work on the board's authority. **THE INSTRUMENTS WENT WITH THE PROSE, and the reason decides what the rule means (operator ruling, 2026-08-24): an ad-hoc `.sh` or `.py` in this repository is ยง6 UNMODELED REALIZATION โ€” raw shell implementing semantics already expressible in `.dag` โ€” so if a measurement is worth re-deriving it is worth an entry point, and if it is not worth an entry point it is not an instrument but a one-off.** This was ruled against a live objection, recorded because the objection was correct on its own facts and still lost: a peer had re-run `curated_cargo_probe_one.sh` that same hour to re-derive the emission board's headline from source, so the scripts had real consumers โ€” LANES, which a census of `.dag` consumers cannot see, and which is why an earlier revision of this clause reported them as dead. The ruling does not deny that consumer; it denies that a stray script is the right carrier for it. So `name the instrument` names a `.dag` entry point, and the rule needs no exception for a referent it deleted: what is deleted is everything that cannot be re-derived from the tree, and what re-derives it is modeled or it does not exist. That is the ยง2 cost of a second representation with no authority, arriving faster than it can be authored, and it is why a measurement document is presumed redundant rather than merely stale. WHAT THIS DOES NOT CLAIM: the cut leaves a named residue it does not repair โ€” gitignore un-ignore rows for deleted paths, prose notes across ~20 modules citing them, and the witnesses above, all still green over dead names. That residue is the next cut, not a gap this one closed. **`executed` answers a narrower question than every reader asks of it: it counts a witness reaching the fold, not its assertion running.** A witness whose subject is a subprocess exit status is planned, discovered, and counted `executed` while it is REFUSED BY CONSTRUCTION at the hermetic boundary โ€” `run_required_floor`'s hermetic envelope rejects the host effect via `shell.Test.IsExecutable` before the subprocess is reached, in about a millisecond, which also rules out a budget refusal on timing. This is CORRECT, not a gap: mocking that refusal to let such a witness run would pass it against a fabricated exit status, the exact fabricated-plausible-output failure the witness exists to catch. It is a boundary of what the hermetic floor can cover, named here beside the counters rather than left for a reader to rediscover. This clause previously read that no witness had executed and that the fold refused during preparation; that was true when written and is now false, and it is corrected in place rather than left standing until the rest of the paragraph can be rewritten โ€” because a knowingly-false recital is premise contamination whichever direction it points, and this one had already cost real work: a session measuring the TestClaim orphan population read it, concluded the floor had never run, and raised a sequencing question about roster growth that does not exist. The rung drop below is unaffected: the fold running green establishes that the replacement executes, not that the re-add queue has closed. WHAT IS UNGUARDED IN THE MEANTIME, named rather than left to be rediscovered (the generated-artifact drift gates were on this list until the `generated-artifact` phase described below took them off it): heal, the seven effect gates, the fmt gate, merge-admission stamping, the falsifier cadence, and the per-witness eval deadline that `gunbc_ci_fast_lane_witness_eval_budget` used to arm (`gunbc.witness_row_cost` `gunbc_ci_fast_lane_rule_note` carries that one's own drop). Each is a separate re-add, each re-derived from its own first principles under its own operator agreement rather than restored from the deleted machinery. RESTORATION TRIGGER: this paragraph is rewritten as an ordinary present-tense description โ€” not amended, rewritten in one pass โ€” when the re-add queue closes. Until then it describes a rung drop, and describing the replacement as finished would be the inflation ยง4b names as worse than sitting low. **WHAT SURVIVES UNTOUCHED, enumerated rather than swept away with the bullet** โ€” this paragraph replaced a longer one, and a prose row is deleted for one reason and takes everything in it unless its contents are enumerated first (the same rule that governs deleting a witness file whole): the compile-clean scope authority `tools.dag_compile_clean_scope` and its import-closure selection (`entry_file_touched_via_import_closure`) are live and unmodified by the cut โ€” but **live is not reachable, and an earlier revision of this clause said only the first**. It read that what is gone is the CI *job* that invoked them and not the authority; both halves are true and together they license a false inference, because SURVIVING A CUT AND BEING CALLABLE ARE DIFFERENT PROPERTIES and only the first was checked. Measured 2026-08-20: `entry_file_touched_via_import_closure` and `compile_clean_scope_plan_for_ci` are private `fn`s with ZERO references anywhere under `src/v1/stage0/src/bin`, no CLI flag reaches them, and the only `pub` surfaces into that chain โ€” `witness_layer_roots_compile_clean_check` / `_emit_check` โ€” return `Bool`, so they answer *is it clean* and can never answer *which entries would be selected*. A reader planning against the old sentence would budget an afternoon and find no caller; that is premise contamination of the same class this paragraph already corrects itself for twice. The authority survives and the capability does not, until something exposes a counting entry point; the `regen_input_sources` import closure survives and is now read by `v1_compiler.required_regen_host` โ€” but the `regen_stage0` binary that consumed it, and the `RegenVerifyGate` / `SelfHostStalenessGate` pair that invoked it, are DELETED at the root (the regen cut), so the self-host fixed point is answered by `claim_executor --required-regen-fixed-point` and by nothing else. **THAT FLAG IS INVOKED AGAIN AS OF 2026-08-20**, so this is one re-add off the queue below rather than a standing gap. It was re-added as two `witnesses.yml` steps ordered ahead of the floor and CONSOLIDATED the same day (operator directive: the phases belong `within the witnesses step and within the gunbc binary, not at a github actions job level`) into ONE step running the phases in ONE process. **THAT 2026-08-20 DIRECTIVE IS NOW PARTLY SUPERSEDED, BY THE SAME OPERATOR, ON 2026-08-25** โ€” `we can add it as a parallel job in github actions - we can do the same for regen now, we have more runners` / `basically i would put regen + v2 full compile in one job, and witnesses into another one`. The stated reason is a change in supply, not a change of mind about the earlier argument: the phases are mutually independent, so composing them into one process made the required check's wall clock a SUM of things that could have been a MAX, and more runners make the MAX purchasable. **THE DIRECTIVE HAS TWO HALVES AND ONLY ONE IS SUPERSEDED, which is why this clause states both rather than replacing one ruling with another.** SURVIVES โ€” *within the gunbc binary*: the phases still live in `claim_executor`, each job makes ONE invocation and names a LANE, and no step's precondition reads another phase's verdict. The step-ladder defect the consolidation fixed (an `if:` naming a sibling step, silently conjoined with GitHub's implicit `success()`, so a regen red disarmed the whole floor) cannot return, because no step's condition can reach another phase's outcome. SUPERSEDED โ€” *not at a github actions job level*: PARALLELISM IS NOT EXPRESSIBLE IN THE BINARY. Two phases running concurrently means two runners, two checkouts and two toolchains, and one process on one runner can thread but cannot acquire a second machine โ€” nor would we want it to, at the floor's measured ~9.4 GiB peak. So the lane boundary is a job boundary of necessity, and what the directive was protecting against โ€” SEQUENCING and PRECONDITIONS leaking into YAML โ€” is exactly what does not cross it: the two jobs carry no `needs` edge and no condition on each other, which is the whole content of running them in parallel. So the invocation named at the top of this paragraph is superseded three times over and is now, exactly: TWO LANE JOBS, `claim_executor --required-ci --source-root dag --source-root src/v2 --required-lane build` (regen's first-generation comparison, the v2-emission compile, the emitted-closure cargo phase and the partition-crate boundary) and the same command with `--required-lane witnesses` (the `.dag` parse sweep and the witness floor fold), plus a THIRD job that gates on both. **THAT THIRD JOB IS NOT DECORATION AND THE FIRST CUT OF THE SPLIT DID NOT HAVE IT, which is the more useful half of this entry.** A GitHub required status check is produced by the JOB, not by the workflow, and the repository's `passing CI` ruleset is active, carries NO bypass actors, and names exactly ONE required context: `witnesses`. So splitting the phases into a second job made regen and v2-emission NON-BLOCKING -- the required check would have gone green over a regen drift or a v2 emission break and the PR would have been mergeable. That is fail-open (ยง5), and strictly worse than the serial run it replaced, because the serial job carried every phase into the one context that gates. THE REPAIR IS AN AGGREGATION JOB RATHER THAN A RULESET EDIT: the floor lane is renamed `floor`, and the name `witnesses` moves to a job that `needs` both lanes and whose only step reads both results and exits nonzero unless both are `success`. **THE CONDITION THAT MAKES THAT JOB RUN IS AT THE JOB LEVEL, AND THE FIRST CUT OF THE AGGREGATOR PUT IT ONLY ON THE STEP** -- the same fail-open committed one level in, caught by review 55795 and independently by a peer session within minutes of each other. `needs` carries an implicit job-level condition: a job that declares `needs` and no `if` is SKIPPED when a needed job fails, a skipped job never reaches its steps, and a step-level `always()` cannot rescue a job that never started. The job therefore declares `always()`, and that is the ONE place in the emission that departs from the file's `!cancelled()` house guard, stated here because a reader who knows the convention will otherwise correct it back and reopen the hole. Every other guard decides whether a STEP runs inside a job that is already running; this one decides WHETHER THE REQUIRED CONTEXT EXISTS AT ALL, and under `!cancelled()` a cancelled run leaves it skipped rather than answered. That turns the outcome on a question about GitHub nobody here has executed -- does a skipped or cancelled required check block a merge -- and the right response is not to measure it but to make the answer not matter: under `always()` the job always runs, always reads both results, and always reports on its own terms, so SKIPPED disappears from the required context. Construction over validation (ยง5), at the cost that a lawfully superseded run now reports this context red rather than cancelled, which is the correct reading rather than a regression. The two lane jobs still carry no `needs` edge on each other and still start together; only the aggregator waits. A ruleset edit would have worked too and was rejected on a boundary this document already records: the ruleset is not a `.dag` fact, so landing a change whose safety depends on someone editing a setting afterwards is a coverage gap with a promise attached and a real unguarded window. **THAT BOUNDARY MOVED, AND THE DECISION IT SUPPORTED DOES NOT.** `gunbc.repo_ruleset` now declares the desired ruleset -- enforcement, target, ref-name condition, the three rules, and the ONE required context, imported from `gunbc.witness_floor_workflow` `witness_floor_workflow_job_id` rather than spelled a second time -- observes the live one through `extdeps.github.rulesets`, reconciles the context roster through `gunbc.membership_reconcile`, applies drift with the whole-ruleset PUT, and READS BACK, claiming convergence only from the second observation and never from the apply's own 200. So the sentence in this bullet stating what the ruleset requires is no longer the only place that fact lives on our side of the boundary: `converge` actuates it and `verify` refuses on any divergence, with a typed reason per way it can diverge. WHAT DID NOT CHANGE, and why the aggregation job stays: convergence is an ACTUATION WITH A READ-BACK, not a wall. Nothing in this repository can constrain what GitHub admits to main, so an in-repo authority still cannot make a required context exist at the moment a merge is attempted -- it can only declare, apply and detect. The first cut's window argument therefore stands on its own terms; what is closed is the invisibility, not the boundary. WHAT IS NOT CLAIMED: NEITHER ENTRY POINT IS ENROLLED IN CI. `verify` is a route somebody runs, and until a required phase invokes it the class sits at *mitigatable* -- a drift is now detectable rather than detected, which is a real climb from unobservable and is not the same as a gate. Its next-rung trigger is that enrolment, which is a separate change under its own operator agreement. Found in review of gunbc#9203, against the live ruleset rather than against the workflow -- which is the only place the fact is visible, since nothing in the emitted YAML says which of its jobs gates. The partition is total by construction โ€” `RequiredCiPhase::lane` is an exhaustive match, so a phase belonging to no job fails to compile rather than going silently unmeasured โ€” and the lane is the ONLY thing the transport names: which phases a lane owns is decided in the binary, never in the YAML. **THE v2-EMISSION PHASE'S SUBJECT WIDENED IN THE SAME CHANGE**, from `dag/std/abi.dag` (the smallest entry in the tree) to `src/v2/compiler/00_compile.dag` (the v2 pipeline root, the widest closure one entry names). The row is `gunbc.ci_layer_roots` `required_v2_emission_entries` and it remains a cost decision rather than a Rust edit; what changed is the denominator, since the build lane's cost is now free up to the floor's duration rather than added to it. **WHAT THE SPLIT DOES NOT DO:** it restores nothing else from the deleted floor machinery โ€” every item on the unguarded list above is still its own re-add under its own operator agreement โ€” and it lands NO ratchet over the v2 compile's advisory-diagnostic population. That population is real and is the natural next subject, but a merge-blocking count pinned to a number measured on the current tree is exactly the oracle ยง5 forbids; an identity-grain monotone debt contract is a separate construction and is not claimed here. **THE PARSE PHASE STOPPED BEING src/v1-ONLY, 2026-08-23**, and it is stated here because the previous revision named the phase by the one root it walked. It now sweeps `src/v1`, `dag` and `src/v2` from one roster (`v1_compiler.cli_run` `DAG_PARSE_SWEEP_ROOTS`), shared with the standalone bin, and it admits source annotations per file rather than parsing alone -- `tokenize` routes `//` into the annotation channel and `parse` never decides grain, so the old walk returned clean for a file carrying an in-body annotation and the ยง4c refusal surfaced only at the floor's strict PREPARATION, where no witness executes at all. That is not a widening of the floor's source roots, which `gunbc.ci_layer_roots` `v1_dead_witness_tree_triage_receipt_remainder` rules out on NAME RESOLUTION grounds: this walk resolves nothing across files, so that objection cannot reach it. **THE PHASE ROSTER WAS CUT TO THREE BY OPERATOR RULING, 2026-08-21, WAS FOUR AGAIN AS OF #9035, AND IS FIVE AS OF THE PARTITION-CRATE PHASE, AND IS SIX AS OF THE EMIT-COMPILE PHASE.** The count is stated in the present tense here and it has now been wrong in BOTH directions, which is the reason it is written as a measurement rather than as a recollection: an earlier revision said four while enumerating a determinism pass no required run performed, that was corrected to three, and the three then went stale when #9035 enrolled a v2-emission phase that compiles one v2 entry โ€” landed precisely because an emission break reached main and no gate could see it. The instrument is the required mode itself, which prints its own roster before any phase runs -- one `phase ` line per phase the lane owns and one `ROUTED to lane ` line per phase it does not, then `lane= phases_run=`. Read the roster from that announcement rather than from this sentence: the count moved twice in five days, and a transcribed roster size is exactly the positional citation the ruling above forbids. The fifth phase is `partition-crates`, in the build lane, which compares the derived stage0 partition's committed `lib.rs` and `Cargo.toml` against what `v1.compiler.stage0_crates` renders from the authority -- landed because those seven crates are workspace members nothing in CI builds, so a broken one sat on main while every required lane stayed green. **THAT COUNT IS NOW A PROPERTY OF THE ROSTER AND NOT OF A RUN**, because the 2026-08-25 split partitions the four across two jobs: a lane's own summary line reports `lane= phases_run=`, and each job additionally prints a `ROUTED to lane ` line for every phase it does not own, so one job's log still names the whole roster and where the rest is being measured. Reading a single job's `phases_run` as the roster size is the error that line exists to prevent. **A SIXTH PHASE, `generated-artifact`, IS THE FIRST ITEM TAKEN OFF THE UNGUARDED LIST ABOVE RATHER THAN A NEW SUBJECT.** It adjudicates every member of `gunbc.generated_artifact` `committed_generated_artifacts` against what its own authority generates, through the pure per-path projection `gunbc.generated_artifact_emit` `generated_artifact_body_for_path`, hosted by `v1_compiler.cli_run` `run_generated_artifact_boundary` in the build lane. THE PRODUCER WAS NEVER MISSING AND THE CALLER WAS, which is why this is a re-add and not a construction: `main_wet` on `dag/gunbc/instruments/generated_artifact_gate.dag` has always written these files and the projection has always been able to say what each path ought to hold, but from the floor cut until this phase the only route that ASKED it was `claim_executor`'s behavioural-receipt census, which asks only about paths of the form `src/v1/stage0/src/` because its subject is emitted Rust mirrors. So `DESIGN.md` and `ROADMAP.md` -- projections of `gunbc.design_document` and `gunbc.roadmap_authority` -- were computed by nothing and compared by nothing, and were maintained BY HAND in lockstep with their authorities. That is not a hypothetical: gunbc#9392 found the accumulated result the only way an unguarded artifact is ever found, by accident while doing something else, 2198 characters into the drift, and this document's own ยง4b(3) clause was among the text standing in the artifact with no authority behind it. **AND THE FIRST THING IT FOUND WAS NOT DRIFT, WHICH IS THE REUSABLE HALF.** Two of its four opening reds were ABSENT rather than drifted, and one of those markdown files had been DELETED ON PURPOSE by a295e17415, whose entire subject is the ruling that the .dag carrier is the authority. Regenerating it would have performed exactly what that commit refused, and the gate would then have gone green over the resurrection -- enforcing, permanently, the reverse of a decision already taken. THE GENERAL SHAPE: **a drift gate makes whatever it adjudicates BINDING**, so an absence that was inert before the gate becomes a line-stop after it, and the cheapest way to move the line is to regenerate. A dormant registry mistake is thereby converted into a standing obligation to recreate deleted files, silently, because the green looks identical either way. The stale half is the REGISTRY ROW and not the missing file, so the repair runs the other way: `gunbc.plan` `PlanProjection` makes a plan declare whether its markdown is a committed artifact at all, `PlanIsAuthorityOnly` carries the ruling that removed the projection, and the plan stays rostered, generated and refusable -- only whether its bytes are expected on disk changes. The rule for the next gate: **before a new adjudicator's first red is closed by producing the thing it says is missing, establish that the thing was ever supposed to exist.** WHAT THE PHASE DOES NOT CLAIM: it is READ-ONLY by construction -- there is no write path in it and no flag that opens one, because a gate that can also write its own subject is a gate whose green proves nothing -- and it restores nothing else from the deleted machinery; heal, the seven effect gates, the fmt gate, merge-admission stamping and the falsifier cadence remain their own re-adds. Its roster is ASKED of the authority, so an artifact added to `generated_artifact_registry` is enrolled with no edit to the host, and a rostered member that reaches NO verdict is counted and reported separately from a drifted one and stops the line on its own -- `0 drifted` over a population nothing asked about is the execution-provenance loss this document names, and the two counts are what keep it unrepresentable. **THAT COUNT IS NOW A PROPERTY OF THE ROSTER AND NOT OF A RUN**, because the 2026-08-25 split partitions the four across two jobs: a lane's own summary line reports `lane= phases_run=`, and each job additionally prints a `ROUTED to lane ` line for every phase it does not own, so one job's log still names the whole roster and where the rest is being measured. Reading a single job's `phases_run` as the roster size is the error that line exists to prevent. The five phases the 2026-08-21 ruling deleted stay deleted and are enumerated below; neither #9035 nor the partition-crate phase restored any of them, each added a new one, so both are roster ADDITIONS and not a reversal of that ruling. Five phases were deleted from the mode: merge-admission-capture, the regen determinism (fixed-point) pass and its in-memory pass-1 digest handoff, the behavioral receipt's controlled-fixture selftest, the behavioral receipt against the authorities a diff changed, and merge-admission-stamp. They were deleted rather than left reporting SKIPPED, because a phase whose only reachable state is a non-verdict has a deficit frequency of zero by construction and still reads as coverage on the ledger (ยง5, the absorbing fallback). The capabilities survive at their own entry points โ€” `--required-regen-fixed-point`, `//gunbc/instruments:behavioral-receipt-plan`, `//gunbc/instruments:behavioral-receipt-selftest`, `//gunbc/instruments:behavioral-receipt-census` โ€” none of which any workflow invokes; what ended is their enrolment in the required run, and the three measurements they carried (regen determinism, behavioural equivalence of a changed authority against its mirror, and the receipt's own discriminating arms) are simply no longer taken. That is a declared scope narrowing, and it returns merge-admission stamping to the unguarded list above rather than removing it from it. The remaining three phases are independent โ€” the one real data dependency left with the phase that consumed it โ€” so every phase runs even after an earlier failure and the run reports the complete ledger instead of letting the first defect hide the rest. The line still stops on any failed phase. It is recorded here with what made the gap real, because the steps were enrolled, STRIPPED, and re-enrolled inside twelve hours and a reader who finds only the enrolment will re-derive the strip. The strip was correct when made: the admission test is whether every red is closable by the author who caused it at the moment they caused it, and it was not -- the comparator normalized BOTH sides through rustfmt while writing the single-pass form, so after any candidate install the comparison was `normalize(normalize(emitted))` against `normalize(emitted)`, an identity only if rustfmt is idempotent. It is not, so the gate refused a tree byte-identical to its own artifact, permanently, and its only reachable green was the hand-edited mirror the gate exists to refuse -- a gate whose sole closing move is the forbidden action is not strict, it launders. Repairing it by raw-comparing the single-pass bytes then put it in direct contradiction with `cargo fmt --all --check`, which re-formats what is committed and so demands the NEXT pass: two gates consuming different passes of one artifact, each breaking the other. The resolution is that the emitted artifact is now written as a FIXED POINT of the formatter (bounded, exceeding the bound is a typed refusal), which makes the contradiction unrepresentable rather than detected and makes `cargo fmt` a no-op on it by definition -- the ยง5 construction move, and the general lesson is that any artifact with two consumers that normalize it must be stored in the normalizer's fixed point or the two consumers cannot both be satisfied. This clause previously read that the fixed point and its closure both survived with only their job removed; that was true when written and the regen cut falsified its first half, so it is rewritten here rather than annotated โ€” a second sentence beside it would be two accounts of one fact; and parse remains grammar-owned โ€” `ingest_yaml_source` (`dag/extdeps/languages/yaml/ingest.dag`), no shell or host parser โ€” which was never a floor fact at all and is restated here so that dropping the bullet does not drop it. **TWO OPERATOR RULINGS ALSO LIVED IN THE REPLACED BULLET AND STILL HOLD**, restated because a ruling about what CI does *not* do is invisible once the paragraph describing CI is rewritten, and nothing in the new mechanism would contradict it loudly: the Rust test suite was removed from CI 2026-07-11 (operator ruling, recorded at `gunbc.commit_workflow` `commit_gate_rust_suite_removed_disposition`) and runs locally only; and clippy was removed from CI 2026-07-08, because a crate-wide `#![allow(clippy::all)]` made it zero-signal over ~44 minutes per run, leaving it a local dev check. Neither is reinstated by the replacement, and neither is the floor cut's to reverse. From 5ba7ade703b98246b68dba097b46a2b9b8060079 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 12:54:20 +0000 Subject: [PATCH 11/25] Execute the authored-source wire round trip in both arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branded-reference cut gave AuthoredSourceContent two arms and gave the codec two encode branches, but nothing executed the claim that the arms survive the wire and stay distinct. A codec branch with no discriminating consumer is specification-without-execution (DESIGN.md ยง5). Two claims now encode a closure holding an empty and a non-empty authored source, decode it, and read the content back off the rebuilt store: one asserts each arm round-trips, the other asserts the two do not collapse into each other. Proven discriminating rather than decorative by mutation: collapsing the text branch of encode_authored_source_content to json_string(s: "") takes both claims red (PASS 0 FAIL 2, exit 1); the codec was restored byte-clean afterwards. Full SCM sweep: 304 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- ...cm_commit_closure_json_v2_witness_test.dag | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 64385c4fb1c..6e98bf8db7c 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -76,6 +76,15 @@ import gunbc.scm.object_store { copy_object, StoredEdge, insert_from_structure, + store_authored_source, + find_object, + ScmObject, + SemanticNodeObject, + AuthoredSourceObject, + AuthoredSourceContent, + AuthoredSourceEmpty, + AuthoredSourceText, + authored_source_content, } import gunbc.scm.checkout { Commit, @@ -1391,3 +1400,142 @@ fn scm_image_role_over_a_node_document() -> JsonValue { test fn scm_image_a_role_whose_value_is_a_node_merges() -> Bool { scm_image_merge_tag_for(document: scm_image_role_over_a_node_document()) == "merged" } + +// ------------------------------------------------------------------------------------------------ +// THE AUTHORED-SOURCE ROUND TRIP, BOTH ARMS, THROUGH THE REAL CODEC. +// +// The re-review's bar asked for this and it was the one item still missing. Every other source claim +// in this file drives a HAND-AUTHORED document into the decoder, which is the right shape for +// refusals -- a refusal fixture must be able to author bytes no encoder would emit. It is the wrong +// shape for the question this asks, which is whether the encoder and decoder agree about a file: +// bytes in, document out, document in, and the SAME object back. +// +// BOTH ARMS, because AuthoredSourceContent has two constructors and the empty one is the one with a +// reason to differ: it has no NonEmptyStr to carry, its identity comes from a different payload, and +// an encoder that dropped it entirely would leave a document that still decodes. The claim compares +// the decoded content against the content the store derived from the original text, so a codec that +// round-tripped the empty file as a one-space file, or as nothing at all, goes red. +// +// The root is a NODE, not either file: a root naming a file is its own refusal +// (scm_image_a_root_naming_a_file_refuses_at_decode), so a round trip rooted at one would be +// asserting the refusal rather than the round trip. +type SourceRoundTrip { + store: ObjectStore + root: NodeTarget + empty_locator: ObjectId + text_locator: ObjectId + ok: Bool +} + +fn scm_image_source_round_trip_fixture() -> SourceRoundTrip { + match store_authored_source(store: empty_store(), text: "") { + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => + SourceRoundTrip { + store: empty_store(), + root: NodeTargetUnresolved(content_hash(n: closure_doc_atom(identity: ^unused))), + empty_locator: content_hash(n: closure_doc_atom(identity: ^unused)), + text_locator: content_hash(n: closure_doc_atom(identity: ^unused)), + ok: false, + } + SourceStored { store: with_empty, source: empty_ref } => + match store_authored_source(store: with_empty, text: "a nonempty file\n") { + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => + SourceRoundTrip { + store: empty_store(), + root: NodeTargetUnresolved(empty_ref.locator), + empty_locator: empty_ref.locator, + text_locator: empty_ref.locator, + ok: false, + } + SourceStored { store: with_both, source: text_ref } => + match store_node(store: with_both, n: closure_doc_atom(identity: ^round_trip_root)) { + LocatorCollision { identity: _, existing: _, incoming: _ } => + SourceRoundTrip { + store: empty_store(), + root: NodeTargetUnresolved(empty_ref.locator), + empty_locator: empty_ref.locator, + text_locator: text_ref.locator, + ok: false, + } + Stored { store: full, node: root } => + SourceRoundTrip { + store: full, + root: NodeTargetResolved(root), + empty_locator: empty_ref.locator, + text_locator: text_ref.locator, + ok: true, + } + } + } + } +} + +fn scm_image_content_at(store: ObjectStore, locator: ObjectId) -> AuthoredSourceContent? { + match find_object(store: store, identity: locator) { + Absent => none + Present { value: object } => + match object { + SemanticNodeObject(_) => none + AuthoredSourceObject(record) => Present { value: record.content } + } + } +} + +fn scm_image_content_matches(store: ObjectStore, locator: ObjectId, text: String) -> Bool { + match scm_image_content_at(store: store, locator: locator) { + Absent => false + Present { value: content } => content == authored_source_content(text: text) + } +} + +test fn scm_image_an_authored_source_survives_the_wire_in_both_arms() -> Bool { + let fx = scm_image_source_round_trip_fixture() + fx.ok + && match decode_closure_document( + v: encode_closure_document(store: fx.store, root: fx.root) + ) { + ClosureDocRefused { cause: _ } => false + ClosureDocLoaded { store: back, root: _ } => + (store_object_count(store: back) == store_object_count(store: fx.store)) + && scm_image_content_matches(store: back, locator: fx.empty_locator, text: "") + && scm_image_content_matches( + store: back, + locator: fx.text_locator, + text: "a nonempty file\n", + ) + } +} + +// THE DISCRIMINATOR THE CLAIM ABOVE NEEDS, and it is about the two arms rather than about presence. +// The empty file and a nonempty one are DIFFERENT objects with different locators, so a codec that +// collapsed the arms -- decoding every source as empty, or the empty one as some fixed text -- would +// satisfy a claim that merely found two objects. This asserts the arms are distinguished on the way +// back: the locator that went in as empty comes back as AuthoredSourceEmpty, and the other as +// AuthoredSourceText. +test fn scm_image_the_two_authored_arms_stay_distinct_across_the_wire() -> Bool { + let fx = scm_image_source_round_trip_fixture() + fx.ok + && !object_id_eq(left: fx.empty_locator, right: fx.text_locator) + && match decode_closure_document( + v: encode_closure_document(store: fx.store, root: fx.root) + ) { + ClosureDocRefused { cause: _ } => false + ClosureDocLoaded { store: back, root: _ } => + match scm_image_content_at(store: back, locator: fx.empty_locator) { + Absent => false + Present { value: empty_content } => + match scm_image_content_at(store: back, locator: fx.text_locator) { + Absent => false + Present { value: text_content } => + match empty_content { + AuthoredSourceEmpty => + match text_content { + AuthoredSourceText(_) => true + AuthoredSourceEmpty => false + } + AuthoredSourceText(_) => false + } + } + } + } +} From 8907b184dc41fcc0fa191ee9a126339c21d1aa1c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 19:24:42 +0000 Subject: [PATCH 12/25] Make a node requirement a proposition, and let only the store answer it The branded-reference recut left StoredEdge.target a two-armed sum, Resolved beside Unresolved, on the reasoning that a partial closure must reference an object it does not carry. The premise was right and the carrier was wrong, and the designated SCM reviewer is what made the difference visible. A child edge states a PROPOSITION -- a semantic node is required at locator L. Whether a store satisfies it is an OBSERVATION, and observations do not belong inside an object whose identity is derived from its content. The four facts offered as evidence the resolved arm was harmless are the proof it never belonged: the hash ignores the arm, records_equal must ignore it, the wire has no spelling for it, and every consumer re-resolves it anyway. Carrying it there was not merely redundant. store_contains_node_target answered Bool, and "false" meant BOTH "nothing is here" and "a file is here" -- states whose remedies are incompatible, since no fetch repairs an occupied locator. Completeness put wrong-kind locators into the fetchable population, and the encoder's position index was over OBJECTS, so it found the file and emitted the CONTAINED spelling. An admitted partial closure produced bytes this same build refuses at decode. - SemanticNodeTarget { locator } is canonical requirement content, plain and publicly constructible, because stating a requirement forges nothing. The sum is deleted, not deprecated: keeping both would leave a second authoring route that bypasses the census. - resolve_node_target projects it into find_node_record, which ALREADY carries the three arms. No peer NodeTargetResolution type is minted; one concept keeps one authority. - The Bool is deleted rather than kept beside the fix. - ClosureCensus separates absent from source_occupied in one traversal and keeps them apart all the way up. unresolved_identities projects absent only; closure_is_complete requires both empty; admission grew ClosureRequiresNodeAtAuthoredSource naming the whole population, so a caller repairing one does not discover the rest one round at a time. - The encoder now carries TWO indexes. Targets resolve against a node-only map, so a contained reference to a file has no entry to find. This is the part that makes the class unwritable rather than checked: .dag has no module privacy, so guarding the two intended entry points would leave the bad publication representable through the importable raw encoder. Red first, and the claim FORBIDS the state rather than describing the emit-then-refuse contradiction, which would have been green on the defect. Three cells one fact apart: source-occupied FAILED while both controls passed; all three pass now. Mutation: routing NodeRecordIsAuthoredSource back into absent takes exactly one claim red. The two authored-source claims now run through serialize_json and parse_json_document, reading content only from the reparsed value. The reviewer proposed a newline payload; mutating the newline escape left both GREEN, because this parser tolerates a raw newline in a string -- the durable leg would have looked rigorous and discriminated nothing. With a quote-bearing payload, mutating the cp==34 escape takes both red. An unreadable parse answers Absent rather than a manufactured ClosureDocRefusal, which would report a decoder verdict for something the decoder never saw. SCM suite 307 pass, 0 fail. Still open on the reviewer's bar: records_equal compares labeled children in supplied order while content_hash_of_children canonicalizes, so the permutation collision is LIVE; parent-first/child-later graft ordering; repository-v1 names under the v2 tag; a frozen v1 document refusing as unsupported protocol. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/checkout.dag | 41 ++-- dag/gunbc/scm/commit_closure.dag | 180 +++++++++++++----- dag/gunbc/scm/commit_closure_json_v2.dag | 67 +++++-- dag/gunbc/scm/merge.dag | 8 +- dag/gunbc/scm/object_store.dag | 109 ++++++----- dag/gunbc/scm/repository_envelope.dag | 10 +- .../scm/scm_checkout_verb_witness_test.dag | 12 +- ...cm_commit_closure_json_v2_witness_test.dag | 70 ++++--- .../scm/scm_commit_closure_witness_test.dag | 177 +++++++++++++++-- .../scm/scm_load_standing_witness_test.dag | 10 +- dag/test/claim/scm/scm_log_witness_test.dag | 12 +- dag/test/claim/scm/scm_merge_witness_test.dag | 32 ++-- .../scm/scm_object_store_witness_test.dag | 14 +- .../scm_repository_envelope_witness_test.dag | 16 +- .../claim/scm/scm_status_witness_test.dag | 8 +- 15 files changed, 531 insertions(+), 235 deletions(-) diff --git a/dag/gunbc/scm/checkout.dag b/dag/gunbc/scm/checkout.dag index 17ba24183a9..6088bb4b0f2 100644 --- a/dag/gunbc/scm/checkout.dag +++ b/dag/gunbc/scm/checkout.dag @@ -15,15 +15,15 @@ import gunbc.scm.object_store { ObjectId, ObjectRecord, SemanticNodeObjectRef, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, ObjectStore, StoredEdge, NodeRecordFound, NodeRecordAbsent, NodeRecordIsAuthoredSource, find_node_record, + resolve_node_target, + node_target_of, } // A COMMIT IS A ROOT IDENTITY AND NOTHING ELSE. No parent, no author, no timestamp, no message, no @@ -32,7 +32,7 @@ import gunbc.scm.object_store { // and a single timeline. Keeping the commit to its root is what lets two commits be compared, // deduplicated and reconstructed without consulting any history at all. type Commit { - root: NodeTarget + root: SemanticNodeTarget } // AN AUTHORED SOURCE AT A PROGRAM POSITION IS ITS OWN REFUSAL, NOT A MISSING OBJECT. @@ -86,20 +86,21 @@ fn checkout_edge_step(store: ObjectStore, acc: CheckoutEdgeAcc, stored: StoredEd } } -// AN UNCONTAINED TARGET IS RESOLVED HERE, NOT PREJUDGED ABSENT. The arm records that the DOCUMENT -// did not carry the object; it does not record that this store lacks it, and a graft may have -// supplied it since. So both arms go through the same lookup and the uncontained one keeps the two -// negatives apart -- absent, or a file -- exactly as the resolved arm does. -fn checkout_node_target(store: ObjectStore, target: NodeTarget) -> CheckoutOutcome { - match target { - NodeTargetResolved(reference) => checkout_object(store: store, identity: reference) - NodeTargetUnresolved(locator) => - match find_node_record(store: store, identity: locator) { - NodeRecordAbsent { identity: i } => CheckoutRefused { cause: MissingObject { identity: i } } - NodeRecordIsAuthoredSource { identity: i } => - CheckoutRefused { cause: ObjectIsAuthoredSource { identity: i } } - NodeRecordFound(record) => checkout_record(store: store, record: record) - } +// A TARGET IS RESOLVED HERE, NEVER PREJUDGED. The edge states that a semantic node is REQUIRED at a +// locator; it says nothing about whether this store satisfies that, and a graft may have supplied it +// since the edge was written. So the target goes through the store's own three-way resolve and the +// two negatives stay apart -- absent, or occupied by a file -- because they are different facts with +// different remedies and only one of them can be repaired by fetching. +// +// This used to match on a two-armed target and route the resolved arm straight to checkout_object. +// That arm is gone: it carried store-relative evidence inside a canonical record, and this function +// re-resolved it anyway, which was one of the four proofs that it never belonged there. +fn checkout_node_target(store: ObjectStore, target: SemanticNodeTarget) -> CheckoutOutcome { + match resolve_node_target(store: store, target: target) { + NodeRecordAbsent { identity: i } => CheckoutRefused { cause: MissingObject { identity: i } } + NodeRecordIsAuthoredSource { identity: i } => + CheckoutRefused { cause: ObjectIsAuthoredSource { identity: i } } + NodeRecordFound(record) => checkout_record(store: store, record: record) } } @@ -149,7 +150,7 @@ fn checkout(store: ObjectStore, commit: Commit) -> CheckoutOutcome { // and reconstruct identically. Round-trip equality is therefore asserted on content identity, never // on occurrence provenance -- and asserting it on provenance would be asserting the one property the // model says must not matter. -fn commit_of_root(root: NodeTarget) -> Commit { +fn commit_of_root(root: SemanticNodeTarget) -> Commit { Commit { root: root } } @@ -217,7 +218,7 @@ fn checkout_repository_at(repo: RepositoryEnvelope, reference: RepositoryCommitR match commit_at_reference(commits: repo.commits, reference: reference) { Absent => CheckoutReferenceUnresolved { reference: reference } Present { value: selected } => - match checkout(store: repo.store, commit: commit_of_root(root: NodeTargetResolved(selected.root))) { + match checkout(store: repo.store, commit: commit_of_root(root: node_target_of(reference: selected.root))) { CheckoutRefused { cause: cause } => CheckoutProgramUnreconstructable { reference: reference, cause: cause } CheckedOut { program: program } => diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index 50afcaf6e34..e2ff5c0f72a 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -86,9 +86,13 @@ import gunbc.scm.object_store { AuthoredSourceObject, ObjectStore, StoredEdge, - NodeTarget, + SemanticNodeTarget, + NodeRecordLookup, + NodeRecordFound, + NodeRecordAbsent, + NodeRecordIsAuthoredSource, object_id_eq, - store_contains_node_target, + resolve_node_target, node_target_locator, store_records_in_dependency_order, } @@ -157,14 +161,18 @@ type AdmittedPartialCommitClosure sole_constructor { reason: PartialClosureReason } -// THE REFUSAL IS AN ARM OF THE OUTCOME, not a one-member coproduct beside it. There is exactly one -// way to be refused here -- the closure has nothing missing -- and wrapping that in its own type -// would either read as a type alias or invite a second arm invented to justify the wrapper. If a -// second genuine refusal appears, it joins this coproduct and every match fails to compile HERE, -// which is the behaviour the nesting was for. +// THE REFUSALS ARE ARMS OF THE OUTCOME, not a coproduct beside it. The note here used to say there +// was exactly one way to be refused -- the closure has nothing missing -- and predicted that a second +// genuine refusal would join this coproduct and break every match at the use sites. That is exactly +// what happened: a closure requiring a semantic node at a locator an authored source occupies is +// UNSATISFIABLE rather than partial, since no fetch can repair an occupied locator, so admitting it +// would mint a transfer nobody can complete. It names the whole population rather than the first, +// because a caller repairing one and retrying should not have to discover the rest one round at a +// time. type PartialClosureAdmissionOutcome = PartialClosureAdmitted { admitted: AdmittedPartialCommitClosure } | ClosureCompleteSoNothingToAdmit + | ClosureRequiresNodeAtAuthoredSource { identities: List } // WHY A CLOSED VOCABULARY RATHER THAN A STRING. A reason nobody can enumerate is a reason nobody can // refuse, and the two admitted causes have genuinely different owners: one is a transfer decision, @@ -177,15 +185,19 @@ fn admit_partial_commit_closure( closure: CommitClosure, reason: PartialClosureReason ) -> PartialClosureAdmissionOutcome { - let unresolved = unresolved_identities(closure: closure) - if list_length(items: unresolved) == 0 { - ClosureCompleteSoNothingToAdmit + let census = closure_census(closure: closure) + if list_length(items: census.source_occupied) > 0 { + ClosureRequiresNodeAtAuthoredSource { identities: census.source_occupied } } else { - PartialClosureAdmitted { - admitted: AdmittedPartialCommitClosure { - closure: closure, - unresolved: unresolved, - reason: reason, + if list_length(items: census.absent) == 0 { + ClosureCompleteSoNothingToAdmit + } else { + PartialClosureAdmitted { + admitted: AdmittedPartialCommitClosure { + closure: closure, + unresolved: census.absent, + reason: reason, + } } } } @@ -206,7 +218,7 @@ fn admitted_unresolved(admitted: AdmittedPartialCommitClosure) -> List // A closure is the store plus the root the commit names. Nothing about how it is written down. type CommitClosure { store: ObjectStore - root: NodeTarget + root: SemanticNodeTarget } // WHY THE WIRE ARM IS "UNCONTAINED" AND THIS FUNCTION SAYS "UNRESOLVED", which is not two names for @@ -234,9 +246,6 @@ type CommitClosure { // An EMPTY list is the complete closure. That is the ordinary case and it is not a special arm -- // completeness is "nothing is missing", derived, never a stored flag that could disagree with the // objects beside it. -fn unresolved_identities(closure: CommitClosure) -> List { - concat(uncontained_targets(store: closure.store), unresolved_root(closure: closure)) -} // TABLE COMPLETENESS WITHOUT A ROOT, because two different consumers ask two different questions and // only one of them has a root to ask about. @@ -269,52 +278,125 @@ fn unresolved_identities(closure: CommitClosure) -> List { // // The realized n is not the argument. A delta encoder folds this over the whole store, and DESIGN ยง6 // rejects "n is small here" as a non-time-stable fact: reuse is what changes n. -fn uncontained_targets(store: ObjectStore) -> List { - reverse( - fold( - store_records_in_dependency_order(store: store), - init: [], - f: fn(acc, object) { uncontained_in_object(store: store, acc: acc, object: object) } - ) - ) -} // AN AUTHORED SOURCE HAS NO OUTBOUND REFERENCES, so it contributes nothing to the unresolved set -- // and that is a fact about its shape rather than a skip. Writing it as a match makes the reason // visible at the site: a later object kind that DOES carry references cannot be added without this // fold refusing to compile, whereas a filter on "is it a node" would silently ignore it. -fn uncontained_in_object(store: ObjectStore, acc: List, object: ScmObject) -> List { - match object { - AuthoredSourceObject(_) => acc - SemanticNodeObject(record) => uncontained_in_record(store: store, acc: acc, record: record) + +// A REQUIREMENT IS UNSATISFIED FOR ONE OF TWO REASONS, AND THEY HAVE DIFFERENT REMEDIES. +// +// This fold used to ask store_contains_node_target, a Bool, and treat every false as a fetchable +// absence. That merged "no object is at L" with "an authored source is at L" -- states whose remedies +// are not merely different but incompatible. An absence is repaired by fetching the object. An +// occupied locator cannot be repaired by fetching anything: something is already there, and it is the +// wrong kind. Putting the second into the population every consumer reads as fetchable is what let an +// admitted partial closure emit bytes this same build refuses at decode, because the encoder's +// position index is over OBJECTS and happily found the file to emit a contained reference to. +// +// So the traversal classifies rather than tests, and the two populations stay apart all the way up. +type ClosureCensus { + absent: List + source_occupied: List +} + +fn empty_census() -> ClosureCensus { + ClosureCensus { absent: [], source_occupied: [] } +} + +// Both lists accumulate by prepend and are reversed once at the top, the idiom this module already +// documents for store_census and then had to be reminded of twice. +fn census_of_target(store: ObjectStore, acc: ClosureCensus, target: SemanticNodeTarget) -> ClosureCensus { + match resolve_node_target(store: store, target: target) { + NodeRecordFound(_) => acc + NodeRecordAbsent { identity: identity } => + ClosureCensus { + absent: concat([identity], acc.absent), + source_occupied: acc.source_occupied, + } + NodeRecordIsAuthoredSource { identity: identity } => + ClosureCensus { + absent: acc.absent, + source_occupied: concat([identity], acc.source_occupied), + } } } -fn uncontained_in_record(store: ObjectStore, acc: List, record: ObjectRecord) -> List { +fn census_in_record(store: ObjectStore, acc: ClosureCensus, record: ObjectRecord) -> ClosureCensus { fold( record.children, init: acc, - f: fn(inner, child) { - if store_contains_node_target(store: store, target: child.target) { - inner - } else { - concat([node_target_locator(target: child.target)], inner) - } - } + f: fn(inner, child) { census_of_target(store: store, acc: inner, target: child.target) } ) } -// THE ROOT IS A REFERENCE LIKE ANY OTHER, and a closure missing its own root is the sharpest case of -// incompleteness there is -- a document that names a commit whose top object it does not carry. -// The predecessor encoded the root positionally too, so this case was as unnameable as the rest. -fn unresolved_root(closure: CommitClosure) -> List { - if store_contains_node_target(store: closure.store, target: closure.root) { - [] - } else { - [node_target_locator(target: closure.root)] +fn census_in_object(store: ObjectStore, acc: ClosureCensus, object: ScmObject) -> ClosureCensus { + match object { + AuthoredSourceObject(_) => acc + SemanticNodeObject(record) => census_in_record(store: store, acc: acc, record: record) } } +fn store_census(store: ObjectStore) -> ClosureCensus { + match fold( + store_records_in_dependency_order(store: store), + init: empty_census(), + f: fn(acc, object) { census_in_object(store: store, acc: acc, object: object) } + ) { + ClosureCensus { absent: a, source_occupied: w } => + ClosureCensus { absent: reverse(a), source_occupied: reverse(w) } + } +} + +// THE ROOT IS A REQUIREMENT LIKE ANY OTHER, so it is classified by the same three-way resolve. A +// closure whose store does not hold its own root is the sharpest incompleteness there is; a closure +// whose root locator holds a FILE is not incomplete at all, it is wrong-kind, and the two must not +// arrive at the caller as one list. +fn root_census(closure: CommitClosure) -> ClosureCensus { + census_of_target(store: closure.store, acc: empty_census(), target: closure.root) +} + +fn closure_census(closure: CommitClosure) -> ClosureCensus { + let table = store_census(store: closure.store) + let root = root_census(closure: closure) + ClosureCensus { + absent: concat(table.absent, root.absent), + source_occupied: concat(table.source_occupied, root.source_occupied), + } +} + +// THE TABLE'S OWN QUESTION, WITHOUT A ROOT, because two consumers ask two different things and only +// one of them has a root to ask about. A COMMIT closure asks "is anything this program reaches +// missing", which includes its own root. A REPOSITORY object table carries many commits over one +// population, so it has no single root and checks its roots separately against the commit list. +// +// This replaces uncontained_targets, which answered one list. It answers the absent population only, +// for the same reason unresolved_identities does: a locator occupied by the wrong kind is not +// something the repository can fetch, and reporting it here would put it back into the population +// whose whole meaning is "get these and the table completes". +fn table_unresolved_identities(store: ObjectStore) -> List { + store_census(store: store).absent +} + +fn table_source_occupied_identities(store: ObjectStore) -> List { + store_census(store: store).source_occupied +} + +// THE FETCHABLE POPULATION IS THE ABSENT ONE, AND ONLY THE ABSENT ONE. Every consumer of this list +// treats membership as "get this object and the closure completes"; a wrong-kind locator would make +// that false, so it is not here. +fn unresolved_identities(closure: CommitClosure) -> List { + closure_census(closure: closure).absent +} + +fn source_occupied_identities(closure: CommitClosure) -> List { + closure_census(closure: closure).source_occupied +} + +// COMPLETE MEANS EVERY REQUIREMENT IS SATISFIED, which is both populations empty. A closure whose +// root or child locator holds a file is not complete -- it is not even partial, since nothing can be +// fetched to finish it. fn closure_is_complete(closure: CommitClosure) -> Bool { - list_length(items: unresolved_identities(closure: closure)) == 0 + let census = closure_census(closure: closure) + (list_length(items: census.absent) == 0) && (list_length(items: census.source_occupied) == 0) } diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 60018965626..84e2d9581c6 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -139,9 +139,7 @@ import gunbc.scm.object_store { AuthoredSourceEmpty, AuthoredSourceText, StoredEdge, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, node_target_locator, ScmObjectRef, SemanticNodeRef, @@ -259,7 +257,7 @@ fn member_set_refusal(v: JsonValue, allowed: List, context: ClosureDocMe } type ClosureDocLoadOutcome - = ClosureDocLoaded { store: ObjectStore, root: NodeTarget } + = ClosureDocLoaded { store: ObjectStore, root: SemanticNodeTarget } | ClosureDocRefused { cause: ClosureDocRefusal } // THE TAG MOVES BECAUSE THE SCHEMA GREW A MEMBER, WHICH IS THIS MODULE'S OWN STATED POLICY. @@ -393,8 +391,26 @@ fn encode_label(l: EdgeLabel) -> List { // The key is the digest STRING rather than the ObjectId record, so lookup identity is exactly the // equality object_id_eq already defines, rather than whatever structural equality a record key would // impose. +// TWO INDEXES, BECAUSE THE TABLE AND THE TARGETS ASK DIFFERENT QUESTIONS. +// +// `by_identity` is every object's position, and it must be, because the table emits every object and +// a position is just its offset. `nodes_by_identity` holds ONLY semantic nodes, and it exists because +// a CHILD TARGET asks something narrower: "does this document carry a PROGRAM at that locator". +// +// One map answered both, and that was the third leg of the wrong-kind defect. A file occupying a +// node's locator is in the object table, so a kind-blind lookup found it and encode_target emitted +// the CONTAINED spelling -- bytes whose own decoder then refuses them, because decode resolves that +// position to an authored source in a node position. The refusal was correct; the emission was not. +// +// SPLITTING THE INDEX IS WHY THIS IS NOW UNWRITABLE RATHER THAN CHECKED. `.dag` has no module +// privacy, so encode_closure_document and encode_object_table are importable and will accept any +// store a caller hands them; guarding only the two intended entry points would leave the bad +// publication perfectly representable through the raw one. External review named exactly that call +// -site census. With the target index carrying only nodes, no reachable caller can emit a contained +// reference to a file, because there is no entry to find. type EncodePositions { by_identity: Map + nodes_by_identity: Map next: Int } @@ -410,6 +426,13 @@ fn position_of(positions: EncodePositions, identity: ObjectId) -> String? { map_lookup(m: positions.by_identity, key: object_id_key(identity: identity)) } +// The lookup a CHILD TARGET gets. Absent means this document carries no program at that locator -- +// whether because nothing is there or because a file is, which are the encoder's same answer and the +// census's different ones. +fn node_position_of(positions: EncodePositions, identity: ObjectId) -> String? { + map_lookup(m: positions.nodes_by_identity, key: object_id_key(identity: identity)) +} + // A missing position is UNREACHABLE rather than defended against, and the reason is worth stating // because the fallback would be invisible: store_records_in_dependency_order yields children before // parents, so by the time an edge is encoded its target already holds a position. Answering some @@ -431,7 +454,7 @@ fn position_of(positions: EncodePositions, identity: ObjectId) -> String? { // Exactly one member, checked. Both arms present, or neither, is a typed refusal rather than a // last-wins read -- the same reason every member set in this module is closed. fn encode_target(positions: EncodePositions, target: ObjectId) -> JsonValue { - match position_of(positions: positions, identity: target) { + match node_position_of(positions: positions, identity: target) { Present { value: r } => json_object(members: [json_kv(key: "at", value: json_string(s: r))]) Absent => json_object( @@ -507,13 +530,16 @@ type EncodeAcc { // accumulator on every step; prepending does not, so the emitted order is restored by one linear // reverse instead of being bought with a quadratic. fn encode_step(acc: EncodeAcc, object: ScmObject) -> EncodeAcc { + let key = object_id_key(identity: scm_object_identity(object: object)) + let reference = position_reference(n: acc.positions.next) EncodeAcc { positions: EncodePositions { - by_identity: map_insert( - m: acc.positions.by_identity, - key: object_id_key(identity: scm_object_identity(object: object)), - value: position_reference(n: acc.positions.next), - ), + by_identity: map_insert(m: acc.positions.by_identity, key: key, value: reference), + nodes_by_identity: match object { + SemanticNodeObject(_) => + map_insert(m: acc.positions.nodes_by_identity, key: key, value: reference) + AuthoredSourceObject(_) => acc.positions.nodes_by_identity + }, next: acc.positions.next + 1, }, objects: concat([encode_object(positions: acc.positions, object: object)], acc.objects), @@ -531,7 +557,14 @@ fn encode_step(acc: EncodeAcc, object: ScmObject) -> EncodeAcc { fn encode_object_table(store: ObjectStore) -> EncodeAcc { match fold( store_records_in_dependency_order(store: store), - init: EncodeAcc { positions: EncodePositions { by_identity: empty_map(), next: 0 }, objects: [] }, + init: EncodeAcc { + positions: EncodePositions { + by_identity: empty_map(), + nodes_by_identity: empty_map(), + next: 0, + }, + objects: [], + }, f: fn(acc, object) { encode_step(acc: acc, object: object) } ) { EncodeAcc { positions: positions, objects: reversed } => @@ -615,7 +648,7 @@ fn an_uncontained_key(missing: List) -> String { }) } -fn encode_closure_document(store: ObjectStore, root: NodeTarget) -> JsonValue { +fn encode_closure_document(store: ObjectStore, root: SemanticNodeTarget) -> JsonValue { let acc = encode_object_table(store: store) let root_reference = encoded_root_reference(acc: acc, root: node_target_locator(target: root)) json_object( @@ -864,7 +897,7 @@ fn decode_edge_step(positions: DecodePositions, acc: EdgeAcc, edge_value: JsonVa TargetUncontained { digest: identity } => EdgeAcc { edges: concat( - [StoredEdge { label: label, target: NodeTargetUnresolved(identity) }], + [StoredEdge { label: label, target: SemanticNodeTarget { locator: identity } }], acc.edges, ), refusal: acc.refusal, @@ -874,7 +907,7 @@ fn decode_edge_step(positions: DecodePositions, acc: EdgeAcc, edge_value: JsonVa Present { value: SemanticNodeRef(node) } => EdgeAcc { edges: concat( - [StoredEdge { label: label, target: NodeTargetResolved(node) }], + [StoredEdge { label: label, target: node_target_of(reference: node) }], acc.edges, ), refusal: acc.refusal, @@ -1140,11 +1173,11 @@ fn decode_closure_document_body(v: JsonValue) -> ClosureDocLoadOutcome { TargetReferenceRefused { cause: c } => ClosureDocRefused { cause: ClosureDocTargetReferenceRefused { cause: c } } TargetUncontained { digest: root } => - ClosureDocLoaded { store: store, root: NodeTargetUnresolved(root) } + ClosureDocLoaded { store: store, root: SemanticNodeTarget { locator: root } } TargetContainedAt { reference: reference } => match resolve_reference(positions: positions, reference: reference) { Present { value: SemanticNodeRef(root) } => - ClosureDocLoaded { store: store, root: NodeTargetResolved(root) } + ClosureDocLoaded { store: store, root: node_target_of(reference: root) } Present { value: AuthoredSourceRef(_) } => ClosureDocRefused { cause: ClosureDocRootIsAuthoredSource { reference: reference } } Absent => @@ -1244,7 +1277,7 @@ fn closure_document_load_standing(cause: ClosureDocRefusal) -> LoadStanding { // predecessor format could not produce: a document whose uncontained references leave objects the // store does not hold loads correctly and is PARTIAL, which is a success with an obligation rather // than a failure. Completeness is derived from the objects, never a flag beside them. -fn loaded_closure_standing(store: ObjectStore, root: NodeTarget) -> LoadStanding { +fn loaded_closure_standing(store: ObjectStore, root: SemanticNodeTarget) -> LoadStanding { if closure_is_complete(closure: CommitClosure { store: store, root: root }) { LoadComplete } else { diff --git a/dag/gunbc/scm/merge.dag b/dag/gunbc/scm/merge.dag index dd395e161d3..48df1c8d8c3 100644 --- a/dag/gunbc/scm/merge.dag +++ b/dag/gunbc/scm/merge.dag @@ -21,9 +21,7 @@ import v2.std.dependency { import gunbc.scm.object_store { ObjectId, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, node_target_locator, ObjectStore, StoreOutcome, @@ -636,7 +634,7 @@ fn root_edge_step(store: ObjectStore, acc: MergeRootAcc, r: ResolvedRole) -> Mer // it will fail to compile here instead of arriving as a fourth spelling of "missing". Found by // external review of gunbc#9891, which is also the review that added the arm this consumer ignored. fn root_edge_for(store: ObjectStore, acc: MergeRootAcc, role: Symbol, object: ObjectId) -> MergeRootAcc { - match checkout_node_target(store: store, target: NodeTargetUnresolved(object)) { + match checkout_node_target(store: store, target: SemanticNodeTarget { locator: object }) { CheckoutRefused { cause: cause } => MergeRootAcc { edges: acc.edges, @@ -766,7 +764,7 @@ fn finish_merge( Stored { store: store, node: node } => Merged { store: store, - commit: Commit { root: NodeTargetResolved(node) }, + commit: Commit { root: node_target_of(reference: node) }, roles: applied.roles, } } diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index 618bab2d24a..0129c1e18a1 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -103,37 +103,48 @@ fn scm_object_ref_locator(reference: ScmObjectRef) -> ObjectId { } } -// A CHILD EDGE HAS TWO HONEST STATES, AND ONE BRAND CANNOT CARRY BOTH. -// -// The obvious cut -- `target: SemanticNodeObjectRef` -- is wrong for a reason the partial-closure -// model makes unavoidable: a document may deliberately omit an object it references, so a decoder -// holds an edge whose target is NOT in the table and whose kind is therefore unknown. Branding it -// anyway would need a mint that records a CLAIM rather than evidence, and the only place to put -// that mint is an exported function -- the importable forge factory this module already recorded -// once, as the way a `sole_constructor` wall gets reopened by the helper written to prove it. -// -// So the target names which state it is in. A target resolved against a store IS a node reference -// and can never be a file; an UNRESOLVED target is a bare locator whose kind nothing has -// established yet, and every consumer that needs a program from it must resolve it and handle both -// negatives. Neither arm can be read as the other, and no forge exists in either direction. -// -// The arms are named for what is KNOWN rather than for where it came from. An earlier spelling said -// "uncontained", which is a fact about one DOCUMENT, and the same shape is needed by merge, whose -// role values are bare locators with no document anywhere in sight. -type NodeTarget - = NodeTargetResolved(SemanticNodeObjectRef) - | NodeTargetUnresolved(ObjectId) - -fn node_target_locator(target: NodeTarget) -> ObjectId { - match target { - NodeTargetResolved(reference) => reference.locator - NodeTargetUnresolved(locator) => locator - } +// A CHILD EDGE CARRIES A REQUIREMENT, NOT EVIDENCE ABOUT SOME STORE. +// +// An earlier revision of this module made the target a two-armed sum -- Resolved(SemanticNodeObjectRef) +// beside Unresolved(ObjectId) -- reasoning that a partial closure must be able to reference an object +// it does not carry, so one brand could not cover both states. The premise was right and the +// conclusion was wrong, and external review (the designated SCM reviewer, at 5ba7ade) is what made +// the difference visible. +// +// What a child edge states is a PROPOSITION: "a semantic node is required at locator L." Whether any +// particular store satisfies it is an OBSERVATION, and observations do not belong inside the object +// whose identity is derived from its content. The four facts that were offered here as evidence the +// resolved arm was harmless are in fact the proof it never belonged: the content hash ignores which +// arm was stored, records_equal must ignore it to avoid fabricating collisions, the wire has no +// spelling for it, and every safe consumer re-resolves both arms against the store anyway. A +// dimension that identity, equality, serialization and every consumer all erase is not record +// content -- it is transient store-relative provenance that was being carried inside the record. +// +// Carrying it there was not merely redundant, it was WRONG, and the defect it produced is executed by +// test.claim.scm_commit_closure_witness `a_node_requirement_over_a_file_is_not_a_fetchable_absence`: +// an authored source occupying L made the requirement unsatisfied, the Bool that consumers read +// reported it as simply absent, completeness called it fetchable, and the kind-blind encoder emitted +// the CONTAINED spelling for it -- so an admitted partial closure produced bytes this same build +// refuses at decode. No fetch could repair it, because L was occupied rather than empty. +// +// So the proposition is a plain record, publicly constructible because stating a requirement forges +// nothing, and the store alone answers it -- through find_node_record, whose three arms already +// separate "no object here" from "a file is here" from "here is the program". +type SemanticNodeTarget { + locator: ObjectId +} + +fn node_target_locator(target: SemanticNodeTarget) -> ObjectId { + target.locator +} + +fn node_target_of(reference: SemanticNodeObjectRef) -> SemanticNodeTarget { + SemanticNodeTarget { locator: reference.locator } } type StoredEdge { label: EdgeLabel - target: NodeTarget + target: SemanticNodeTarget } type ObjectRecord sole_constructor { @@ -359,12 +370,20 @@ fn store_holds_node_at(store: ObjectStore, locator: ObjectId) -> Bool { } } -// A CHILD EDGE IS RESOLVED, NEVER READ. Both arms end at the same question -- does THIS store hold a -// semantic node under that locator -- and asking it here is what stops a consumer from treating the -// resolved arm as self-evidently present (it may name a node from another store) or the uncontained -// arm as self-evidently absent (a later graft may have supplied it). -fn store_contains_node_target(store: ObjectStore, target: NodeTarget) -> Bool { - store_holds_node_at(store: store, locator: node_target_locator(target: target)) +// A CHILD EDGE IS RESOLVED THREE WAYS, NEVER TWO. +// +// This replaces store_contains_node_target, which answered Bool. That Bool is the whole defect the +// branded-reference recut was supposed to close and did not: "false" meant BOTH "no object is here" +// and "a file is here", and every consumer read it as the first. Completeness put a wrong-kind +// locator into the fetchable population, and encoding -- whose position index is over objects, not +// over nodes -- then found something at that locator and emitted the CONTAINED spelling. The two +// answers must not be merged, so the Bool is deleted rather than kept beside this: a caller that only +// needs presence at a LOCATOR calls store_contains, which is honestly kind-agnostic. +// +// find_node_record already carries the three arms, so this mints no second authority for the +// question -- it is the same observation asked of a requirement rather than of a bare identity. +fn resolve_node_target(store: ObjectStore, target: SemanticNodeTarget) -> NodeRecordLookup { + find_node_record(store: store, identity: target.locator) } fn store_contains(store: ObjectStore, identity: ObjectId) -> Bool { @@ -449,16 +468,20 @@ fn insert_admission(store: ObjectStore, object: ScmObject) -> InsertAdmission { // Record equality is the substrate's own structural equality over both fields. StoredEdge is a // label and a derived child identity, so comparing the child LISTS compares the whole subtree by // its already-derived identities -- no re-walk, no digest projection, no hand-written pairwise fold. -// EQUALITY IS OVER WHAT THE IDENTITY IS DERIVED FROM, AND THE TARGET'S ARM IS NOT PART OF THAT. +// EQUALITY IS OVER WHAT THE IDENTITY IS DERIVED FROM. +// +// This was `left.children == right.children`, the substrate's structural equality, which was right +// while a StoredEdge held a bare locator. It stopped being right when the target briefly became a +// two-armed sum: Resolved(r) and Unresolved(l) over the SAME locator were structurally different and +// the same object by content, because content_hash_of_children consumes only the locator. Structural +// equality reported "different record" for two records the identity rule cannot distinguish, and +// insert_admission turned that into a LocatorCollision -- a fabricated refusal for an ordinary write. // -// This was `left.children == right.children`, the substrate's structural equality, which was exactly -// right while a StoredEdge held a bare locator. It stopped being right when the target became a -// NodeTarget: `NodeTargetResolved(r)` and `NodeTargetUnresolved(l)` over the SAME locator are -// different values structurally and the same object by content, because content_hash_of_children -// consumes only the locator. Structural equality therefore reported "different record" for two -// records the identity rule cannot distinguish, and insert_admission turned that into a -// LocatorCollision -- a fabricated refusal, for a store that was being asked to do something -// perfectly ordinary. +// THE SUM IS GONE NOW and the projection below is kept anyway, deliberately. It is no longer +// defending against the arm; it is stating that equality is over the identity PREIMAGE, which is what +// content_hash_of_children folds. Comparing the final digest instead would suppress the honest +// hash-collision refusal, and comparing raw structure again would re-admit any future field that the +// preimage does not consume. // // Found by a control, not by review: the file arm of the merge role claim passed and its NODE // control failed, because merge reassembles the root from resolved children and re-inserts it over a @@ -636,7 +659,7 @@ fn store_node(store: ObjectStore, n: Node) -> StoreOutcome { StoreEdgeAcc { store: child_store, edges: concat( - [StoredEdge { label: edge.label, target: NodeTargetResolved(child_node) }], + [StoredEdge { label: edge.label, target: node_target_of(reference: child_node) }], acc.edges, ), collision: none, diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index f989b2a0aa3..b89acd2c7c5 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -86,14 +86,14 @@ import gunbc.scm.object_store { NodeRecordIsAuthoredSource, ObjectId, ObjectStore, - NodeTarget, + SemanticNodeTarget, SemanticNodeObjectRef, node_target_locator, scm_object_ref_locator, empty_store, find_node_record, } -import gunbc.scm.commit_closure { uncontained_targets } +import gunbc.scm.commit_closure { table_unresolved_identities } import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, DecodeAcc, @@ -324,7 +324,7 @@ type RepositoryCommitMint // refusal to appear and the wrong owner to name in it. fn mint_repository_commit( repository: RepositoryEnvelope, - root: NodeTarget, + root: SemanticNodeTarget, message: String, parent: RepositoryCommitRef?, ) -> RepositoryCommitMint { @@ -535,10 +535,10 @@ type RepositoryEncodeOutcome // and nothing downstream depends on which. Naming it `first_` promised a stable ordering the witness // does not check -- and could not cheaply check, since with a single uncontained object every member // is also the first. Pinning order here would additionally freeze an incidental traversal order into -// the interface, which a later keyed or canonical representation of uncontained_targets should not +// the interface, which a later keyed or canonical representation of table_unresolved_identities should not // be forced to preserve. fn an_uncontained_target(store: ObjectStore) -> ObjectId? { - fold(uncontained_targets(store: store), init: none, f: fn(acc, id) { + fold(table_unresolved_identities(store: store), init: none, f: fn(acc, id) { match acc { Present { value: _ } => acc Absent => Present { value: id } diff --git a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag index 0673d35ce3f..3f9e0534473 100644 --- a/dag/test/claim/scm/scm_checkout_verb_witness_test.dag +++ b/dag/test/claim/scm/scm_checkout_verb_witness_test.dag @@ -19,9 +19,7 @@ import gunbc.scm.object_store { store_node, Stored, LocatorCollision, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, } import gunbc.scm.repository_envelope { RepositoryEnvelope, @@ -74,14 +72,14 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly // duplicates (#8707), which this layer's witness files already document hitting. type ScmCkPut { store: ObjectStore - identity: NodeTarget + identity: SemanticNodeTarget } fn scm_ck_put(store: ObjectStore, n: Node) -> ScmCkPut { match store_node(store: store, n: n) { - Stored { store: s, node: node } => ScmCkPut { store: s, identity: NodeTargetResolved(node) } + Stored { store: s, node: node } => ScmCkPut { store: s, identity: node_target_of(reference: node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - ScmCkPut { store: store, identity: NodeTargetUnresolved(identity) } + ScmCkPut { store: store, identity: SemanticNodeTarget { locator: identity } } } } @@ -102,7 +100,7 @@ fn scm_ck_repository_with_store(store: ObjectStore) -> RepositoryEnvelope { } } -fn scm_ck_minted_or(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn scm_ck_minted_or(repo: RepositoryEnvelope, root: SemanticNodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 6e98bf8db7c..f38f3766564 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -61,9 +61,7 @@ import gunbc.scm.object_store { LocatorCollision, SourceStored, SourceLocatorCollision, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, node_target_locator, empty_store, object_id_eq, @@ -194,14 +192,14 @@ fn scm_image_shared_program() -> Node { type ScmImagePut { store: ObjectStore - identity: NodeTarget + identity: SemanticNodeTarget } fn scm_image_put(store: ObjectStore, n: Node) -> ScmImagePut { match store_node(store: store, n: n) { - Stored { store: s, node: node } => ScmImagePut { store: s, identity: NodeTargetResolved(node) } + Stored { store: s, node: node } => ScmImagePut { store: s, identity: node_target_of(reference: node) } LocatorCollision { identity: i, existing: _, incoming: _ } => - ScmImagePut { store: store, identity: NodeTargetUnresolved(i) } + ScmImagePut { store: store, identity: SemanticNodeTarget { locator: i } } } } @@ -743,11 +741,11 @@ fn scm_image_insert_children(kind: NodeKind, children: List, fallbac // carry; the assembly rule under test consumes the locator either way, so the claim is unchanged // while the fixture stops asserting a kind it did not establish. fn scm_image_named_stored(name: Symbol, target: ObjectId) -> StoredEdge { - StoredEdge { label: Named { name: name }, target: NodeTargetUnresolved(target) } + StoredEdge { label: Named { name: name }, target: SemanticNodeTarget { locator: target } } } fn scm_image_positional_stored(target: ObjectId) -> StoredEdge { - StoredEdge { label: Positional, target: NodeTargetUnresolved(target) } + StoredEdge { label: Positional, target: SemanticNodeTarget { locator: target } } } // NAMED children permuted: the SAME identity, and it is the identity the ordinary content_hash of @@ -1058,7 +1056,7 @@ fn scm_image_checkout_cause_tag(loaded: ClosureDocLoadOutcome) -> String { // decode could tell a node's child from a file, so the store was built with the file wired into a // child edge and the fault surfaced one layer later, at the consumer. // -// A child edge now carries a NodeTarget, whose resolved arm is a SemanticNodeObjectRef, so the +// A child edge now carries a SemanticNodeTarget, whose resolved arm is a SemanticNodeObjectRef, so the // decoder cannot write the file there at all -- it has to resolve the position, discover an authored // entry, and refuse. So the cause is `ObjectTableEdgeTargetIsAuthoredSource`, owned by the layer // reading the document, and the store that would have carried the lie is never built. The claim goes @@ -1421,7 +1419,7 @@ test fn scm_image_a_role_whose_value_is_a_node_merges() -> Bool { // asserting the refusal rather than the round trip. type SourceRoundTrip { store: ObjectStore - root: NodeTarget + root: SemanticNodeTarget empty_locator: ObjectId text_locator: ObjectId ok: Bool @@ -1432,17 +1430,17 @@ fn scm_image_source_round_trip_fixture() -> SourceRoundTrip { SourceLocatorCollision { identity: _, existing: _, incoming: _ } => SourceRoundTrip { store: empty_store(), - root: NodeTargetUnresolved(content_hash(n: closure_doc_atom(identity: ^unused))), + root: SemanticNodeTarget { locator: content_hash(n: closure_doc_atom(identity: ^unused)) }, empty_locator: content_hash(n: closure_doc_atom(identity: ^unused)), text_locator: content_hash(n: closure_doc_atom(identity: ^unused)), ok: false, } SourceStored { store: with_empty, source: empty_ref } => - match store_authored_source(store: with_empty, text: "a nonempty file\n") { + match store_authored_source(store: with_empty, text: "a \"quoted\" file\n") { SourceLocatorCollision { identity: _, existing: _, incoming: _ } => SourceRoundTrip { store: empty_store(), - root: NodeTargetUnresolved(empty_ref.locator), + root: SemanticNodeTarget { locator: empty_ref.locator }, empty_locator: empty_ref.locator, text_locator: empty_ref.locator, ok: false, @@ -1452,7 +1450,7 @@ fn scm_image_source_round_trip_fixture() -> SourceRoundTrip { LocatorCollision { identity: _, existing: _, incoming: _ } => SourceRoundTrip { store: empty_store(), - root: NodeTargetUnresolved(empty_ref.locator), + root: SemanticNodeTarget { locator: empty_ref.locator }, empty_locator: empty_ref.locator, text_locator: text_ref.locator, ok: false, @@ -1460,7 +1458,7 @@ fn scm_image_source_round_trip_fixture() -> SourceRoundTrip { Stored { store: full, node: root } => SourceRoundTrip { store: full, - root: NodeTargetResolved(root), + root: node_target_of(reference: root), empty_locator: empty_ref.locator, text_locator: text_ref.locator, ok: true, @@ -1488,20 +1486,41 @@ fn scm_image_content_matches(store: ObjectStore, locator: ObjectId, text: String } } +// THE DURABLE LEG, AND IT IS LOAD-BEARING BY DATAFLOW RATHER THAN BY THIS COMMENT. +// +// Both source claims below used to hand decode_closure_document the value encode returned, in memory. +// That executes the codec but NOT the wire: the payload carries a newline, and inside a JsonString a +// newline is just a character -- its escaping and re-parsing are never exercised, so a defect at the +// serialize/parse boundary would leave both claims green. Review named this precisely and it was +// right; the earlier commit message calling this route a round trip through the wire overstated it. +// +// The only successful arm returns the REPARSED value, so a claim physically cannot read reconstructed +// content from anything but text that was serialized and parsed back. An unreadable parse is false. +// AN UNREADABLE PARSE IS ABSENT, NOT A DECODE REFUSAL. Manufacturing some ClosureDocRefusal arm here +// would report a decoder verdict for something the decoder never saw -- a fabricated plausible output +// of exactly the kind DESIGN section 5 forbids, and it would make a serializer defect arrive wearing +// the decoder's name. The option has no arm to lie in: Absent means the text did not parse, and every +// caller answers false to it. +fn scm_image_reload_through_text(store: ObjectStore, root: SemanticNodeTarget) -> ClosureDocLoadOutcome? { + match parse_json_document(s: serialize_json(v: encode_closure_document(store: store, root: root))) { + JsonDocumentUnreadable { gap: _ } => none + JsonDocumentParsed { value: reparsed } => Present { value: decode_closure_document(v: reparsed) } + } +} + test fn scm_image_an_authored_source_survives_the_wire_in_both_arms() -> Bool { let fx = scm_image_source_round_trip_fixture() fx.ok - && match decode_closure_document( - v: encode_closure_document(store: fx.store, root: fx.root) - ) { - ClosureDocRefused { cause: _ } => false - ClosureDocLoaded { store: back, root: _ } => + && match scm_image_reload_through_text(store: fx.store, root: fx.root) { + Absent => false + Present { value: ClosureDocRefused { cause: _ } } => false + Present { value: ClosureDocLoaded { store: back, root: _ } } => (store_object_count(store: back) == store_object_count(store: fx.store)) && scm_image_content_matches(store: back, locator: fx.empty_locator, text: "") && scm_image_content_matches( store: back, locator: fx.text_locator, - text: "a nonempty file\n", + text: "a \"quoted\" file\n", ) } } @@ -1516,11 +1535,10 @@ test fn scm_image_the_two_authored_arms_stay_distinct_across_the_wire() -> Bool let fx = scm_image_source_round_trip_fixture() fx.ok && !object_id_eq(left: fx.empty_locator, right: fx.text_locator) - && match decode_closure_document( - v: encode_closure_document(store: fx.store, root: fx.root) - ) { - ClosureDocRefused { cause: _ } => false - ClosureDocLoaded { store: back, root: _ } => + && match scm_image_reload_through_text(store: fx.store, root: fx.root) { + Absent => false + Present { value: ClosureDocRefused { cause: _ } } => false + Present { value: ClosureDocLoaded { store: back, root: _ } } => match scm_image_content_at(store: back, locator: fx.empty_locator) { Absent => false Present { value: empty_content } => diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index f59bceaea09..b8f864271fa 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -12,7 +12,7 @@ module test.claim.scm_commit_closure_witness // the returned identity is THE MISSING OBJECT'S OWN, which is what makes it fetchable rather than // merely non-empty. `a_grafted_root_names_exactly_the_child_it_lacks` is the test that decides it. -import std.types { Bool, Int, List, list_length } +import std.types { Bool, Int, String, List, list_length } import v2.std.node { Node, Edge, Named, Conj, Atom, TypeNode, node_synthetic } import gunbc.scm.object_store { ObjectId, @@ -20,9 +20,7 @@ import gunbc.scm.object_store { StoreOutcome, Stored, LocatorCollision, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, node_target_locator, CopyOutcome, Copied, @@ -33,6 +31,12 @@ import gunbc.scm.object_store { store_node, object_id_eq, store_object_count, + store_authored_source, + SourceStoreOutcome, + SourceStored, + SourceLocatorCollision, + insert_from_structure, + StoredEdge, } import v2.std.collection { empty_map } import extdeps.languages.json.emit { serialize_json, json_object, json_kv, json_string, JsonValue, JsonObject } @@ -75,6 +79,7 @@ import gunbc.scm.commit_closure { PartialClosureAdmissionOutcome, PartialClosureAdmitted, ClosureCompleteSoNothingToAdmit, + ClosureRequiresNodeAtAuthoredSource, admitted_reason, admitted_unresolved, unresolved_identities, @@ -93,17 +98,17 @@ fn parent_node() -> Node { } // A store holding the parent AND its child, with the parent's identity. -// THE ROOT IS A NodeTarget AND THE CHILD IS A LOCATOR, which is the two questions this fixture is +// THE ROOT IS A SemanticNodeTarget AND THE CHILD IS A LOCATOR, which is the two questions this fixture is // actually asked. The root is handed to CommitClosure, a node-requiring position; the child is only // ever compared against the identities a completeness fold reports, which are addresses. -type BuiltStore { store: ObjectStore, root: NodeTarget, child: ObjectId } +type BuiltStore { store: ObjectStore, root: SemanticNodeTarget, child: ObjectId } fn build_full_store() -> BuiltStore { match store_node(store: empty_store(), n: child_node()) { LocatorCollision { identity: _, existing: _, incoming: _ } => BuiltStore { store: empty_store(), - root: NodeTargetUnresolved(fallback_id()), + root: SemanticNodeTarget { locator: fallback_id() }, child: fallback_id(), } Stored { store: s1, node: child_node_ref } => @@ -111,13 +116,13 @@ fn build_full_store() -> BuiltStore { LocatorCollision { identity: _, existing: _, incoming: _ } => BuiltStore { store: empty_store(), - root: NodeTargetUnresolved(fallback_id()), + root: SemanticNodeTarget { locator: fallback_id() }, child: fallback_id(), } Stored { store: s2, node: parent_ref } => BuiltStore { store: s2, - root: NodeTargetResolved(parent_ref), + root: node_target_of(reference: parent_ref), child: child_node_ref.locator, } } @@ -154,7 +159,7 @@ fn build_grafted_store() -> ObjectStore { // ONE missing identity -- and a one-element list reversed is itself. So the unresolved list could // have been emitted in any order and nothing here would have noticed. // -// That was not hypothetical. uncontained_targets accumulates by prepend and reverses once (the cost +// That was not hypothetical. the census fold accumulates by prepend and reverses once (the cost // shape DESIGN section 6 requires); dropping that reverse was measured to leave every claim in this // file GREEN. A comment asserting the reverse was load-bearing would have been describing a wall // that nothing held up, which is the failure this stack has now produced three times. @@ -179,12 +184,12 @@ fn closure_two_child_parent_node() -> Node { ) } -type TwoChildStore { store: ObjectStore, root: NodeTarget, first: ObjectId, second: ObjectId } +type TwoChildStore { store: ObjectStore, root: SemanticNodeTarget, first: ObjectId, second: ObjectId } fn two_child_fallback() -> TwoChildStore { TwoChildStore { store: empty_store(), - root: NodeTargetUnresolved(fallback_id()), + root: SemanticNodeTarget { locator: fallback_id() }, first: fallback_id(), second: fallback_id(), } @@ -202,7 +207,7 @@ fn build_two_child_store() -> TwoChildStore { Stored { store: s3, node: parent_ref } => TwoChildStore { store: s3, - root: NodeTargetResolved(parent_ref), + root: node_target_of(reference: parent_ref), first: alpha_ref.locator, second: beta_ref.locator, } @@ -296,9 +301,11 @@ test fn an_admission_carries_the_decision_that_produced_it() -> Bool { let closure = CommitClosure { store: grafted, root: full.root } match admit_partial_commit_closure(closure: closure, reason: ClosureTrimmedForTransfer) { ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false PartialClosureAdmitted { admitted: transfer } => match admit_partial_commit_closure(closure: closure, reason: ClosureGraftedWithoutChildren) { ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false PartialClosureAdmitted { admitted: graft } => reason_is_transfer(r: admitted_reason(admitted: transfer)) && !reason_is_transfer(r: admitted_reason(admitted: graft)) @@ -314,6 +321,7 @@ test fn an_admission_names_the_objects_it_admits_as_missing() -> Bool { let grafted = build_grafted_store() match admitted_for(store: grafted, root: full.root) { ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false PartialClosureAdmitted { admitted: a } => (list_length(items: admitted_unresolved(admitted: a)) == 1) && names_exactly(missing: admitted_unresolved(admitted: a), expected: full.child) @@ -331,13 +339,14 @@ test fn the_mint_refuses_a_complete_closure() -> Bool { match admitted_for(store: full.store, root: full.root) { PartialClosureAdmitted { admitted: _ } => false ClosureCompleteSoNothingToAdmit => true + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false } } // Mints an admission for the closure under test, or reports the refusal. Every claim below routes // through this, so none of them can accidentally encode a closure the mint never admitted. -fn admitted_for(store: ObjectStore, root: NodeTarget) -> PartialClosureAdmissionOutcome { +fn admitted_for(store: ObjectStore, root: SemanticNodeTarget) -> PartialClosureAdmissionOutcome { admit_partial_commit_closure( closure: CommitClosure { store: store, root: root }, reason: ClosureTrimmedForTransfer @@ -383,6 +392,7 @@ test fn a_partial_closure_encodes_only_with_an_admission() -> Bool { } let admitted = match admitted_for(store: grafted, root: full.root) { ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false PartialClosureAdmitted { admitted: a } => json_is_object(v: encode_admitted_partial_closure_document(admitted: a)) } @@ -419,6 +429,7 @@ test fn an_uncontained_object_survives_the_wire_and_is_still_named() -> Bool { let grafted = build_grafted_store() match admitted_for(store: grafted, root: full.root) { ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false PartialClosureAdmitted { admitted: a } => match parse_json_document( s: serialize_json(v: encode_admitted_partial_closure_document(admitted: a)) @@ -532,3 +543,141 @@ test fn a_non_string_at_is_refused_against_at() -> Bool { TargetUncontained { digest: _ } => false } } + +// ------------------------------------------------------------------------------------------------ +// A NODE REQUIREMENT WHOSE LOCATOR IS OCCUPIED BY A FILE IS NOT A FETCHABLE ABSENCE. +// +// The three cells below differ by ONE FACT -- what, if anything, sits at the child's locator -- and +// they exist because the branch's own carrier cannot tell two of them apart. store_contains_node_target +// asks store_holds_node_at, which asks for a SEMANTIC NODE, so an authored source at L answers false; +// uncontained_in_record reads every false as a fetchable missing locator and has no wrong-kind arm. +// +// THE ASSERTION IS A PROHIBITION, NOT A DESCRIPTION, and that distinction is the whole design of this +// test. The tempting claim -- "the encoder emits a document this build then refuses" -- is TRUE on the +// defect, so it would be green on the bug it is meant to catch. What must be forbidden is the state +// itself: a locator occupied by the wrong kind may not inhabit the unresolved population, because +// every consumer of that population treats membership as "absent, therefore fetchable" and fetching +// cannot repair an occupied locator. +// +// Each control blocks a repair that would satisfy the others while still being wrong: +// - source_occupied alone is satisfied by refusing EVERY unresolved target, which would destroy the +// partial closure the format exists to carry. absent_child forbids that. +// - absent_child alone is satisfied by treating ANY object at L as sufficient regardless of kind, +// which is the defect restated. source_occupied forbids that. +// - both are satisfied by encoding every target as uncontained, dodging the self-refusal by never +// emitting the contained spelling. present_child forbids that: it requires "at" to round-trip to +// a complete closure. + +fn closure_occupied_file_text() -> String { + "not a program\n" +} + +// The parent is built through insert_from_structure rather than by hand: ObjectRecord is +// sole_constructor, so the fixture chooses the STRUCTURE and the store derives the identity. The +// child target is therefore incomplete-by-construction and this fixture never forges a reference. +type OccupiedFixture { store: ObjectStore, root: SemanticNodeTarget, child: ObjectId, ok: Bool } + +fn closure_over_an_occupied_locator() -> OccupiedFixture { + match store_authored_source(store: empty_store(), text: closure_occupied_file_text()) { + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => + OccupiedFixture { + store: empty_store(), + root: SemanticNodeTarget { locator: fallback_id() }, + child: fallback_id(), + ok: false, + } + SourceStored { store: with_file, source: file } => + match insert_from_structure( + store: with_file, + kind: TypeNode { connective: Conj }, + children: [ + StoredEdge { label: Named { name: ^only_child }, target: SemanticNodeTarget { locator: file.locator } }, + ], + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => + OccupiedFixture { + store: empty_store(), + root: SemanticNodeTarget { locator: fallback_id() }, + child: fallback_id(), + ok: false, + } + Stored { store: assembled, node: root } => + OccupiedFixture { + store: assembled, + root: node_target_of(reference: root), + child: file.locator, + ok: true, + } + } + } +} + +fn names_none_of(missing: List, forbidden: ObjectId) -> Bool { + fold( + missing, + init: true, + f: fn(acc, m) { acc && !object_id_eq(left: m, right: forbidden) } + ) +} + +// CELL A -- the discriminating red. A file occupies the child's locator, so the requirement is +// UNSATISFIED but not ABSENT: it is wrong-kind, and no fetch can repair it. It must not appear in the +// population every consumer reads as fetchable. +test fn a_node_requirement_over_a_file_is_not_a_fetchable_absence() -> Bool { + let fx = closure_over_an_occupied_locator() + fx.ok + && names_none_of( + missing: unresolved_identities(closure: CommitClosure { store: fx.store, root: fx.root }), + forbidden: fx.child + ) +} + +// CELL B -- the absent control, one fact from cell A. Nothing occupies the locator, so the +// requirement IS a genuine absence: it stays in the unresolved population, admits as partial, and +// survives encode, TEXT, and decode still naming the object it could not carry. +test fn an_absent_child_is_still_a_fetchable_absence() -> Bool { + let full = build_full_store() + let grafted = build_grafted_store() + match admitted_for(store: grafted, root: full.root) { + ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false + PartialClosureAdmitted { admitted: a } => + match parse_json_document( + s: serialize_json(v: encode_admitted_partial_closure_document(admitted: a)) + ) { + JsonDocumentUnreadable { gap: _ } => false + JsonDocumentParsed { value: reparsed } => + match decode_closure_document(v: reparsed) { + ClosureDocRefused { cause: _ } => false + ClosureDocLoaded { store: store, root: root } => + names_exactly( + missing: unresolved_identities(closure: CommitClosure { store: store, root: root }), + expected: full.child + ) + } + } + } +} + +// CELL C -- the present control, the other fact from cell A. A real semantic node occupies the +// locator, so the closure is COMPLETE: it encodes the contained spelling and round-trips through text +// to a closure that names nothing missing. This is what forbids the repair that encodes every target +// as uncontained to dodge the contradiction. +test fn a_present_child_round_trips_as_a_complete_closure() -> Bool { + let full = build_full_store() + match encode_complete_closure_document( + closure: CommitClosure { store: full.store, root: full.root } + ) { + ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: encoded } => + match parse_json_document(s: serialize_json(v: encoded)) { + JsonDocumentUnreadable { gap: _ } => false + JsonDocumentParsed { value: reparsed } => + match decode_closure_document(v: reparsed) { + ClosureDocRefused { cause: _ } => false + ClosureDocLoaded { store: store, root: root } => + closure_is_complete(closure: CommitClosure { store: store, root: root }) + } + } + } +} diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index dff1d6292e8..fbf2eb05ba4 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -14,7 +14,7 @@ import std.types { Bool, Int, List } import v2.std.node { Node, Edge, Named, Conj, Atom, TypeNode, node_synthetic } import gunbc.scm.object_store { ObjectId, ObjectStore, StoreOutcome, Stored, LocatorCollision, - NodeTarget, NodeTargetResolved, NodeTargetUnresolved, node_target_locator, + SemanticNodeTarget, node_target_locator, CopyOutcome, Copied, CopySourceAbsent, CopyLocatorCollision, empty_store, copy_object, store_node, } @@ -68,17 +68,17 @@ fn ls_parent() -> Node { ) } -type LsBuilt { store: ObjectStore, root: NodeTarget } +type LsBuilt { store: ObjectStore, root: SemanticNodeTarget } fn ls_full() -> LsBuilt { match store_node(store: empty_store(), n: ls_child()) { LocatorCollision { identity: i, existing: _, incoming: _ } => - LsBuilt { store: empty_store(), root: NodeTargetUnresolved(i) } + LsBuilt { store: empty_store(), root: SemanticNodeTarget { locator: i } } Stored { store: s1, node: _ } => match store_node(store: s1, n: ls_parent()) { LocatorCollision { identity: i, existing: _, incoming: _ } => - LsBuilt { store: empty_store(), root: NodeTargetUnresolved(i) } - Stored { store: s2, node: p } => LsBuilt { store: s2, root: NodeTargetResolved(p) } + LsBuilt { store: empty_store(), root: SemanticNodeTarget { locator: i } } + Stored { store: s2, node: p } => LsBuilt { store: s2, root: node_target_of(reference: p) } } } } diff --git a/dag/test/claim/scm/scm_log_witness_test.dag b/dag/test/claim/scm/scm_log_witness_test.dag index 8e2e46d5af0..57420d35833 100644 --- a/dag/test/claim/scm/scm_log_witness_test.dag +++ b/dag/test/claim/scm/scm_log_witness_test.dag @@ -11,9 +11,7 @@ import gunbc.scm.object_store { store_node, Stored, LocatorCollision, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, } import gunbc.scm.ancestry { RepositoryCommitRef } import gunbc.scm.repository_envelope { @@ -47,15 +45,15 @@ fn log_witness_atom(name: Symbol) -> Node { type LogWitnessStored { store: ObjectStore - identity: NodeTarget + identity: SemanticNodeTarget } fn log_witness_store_one(store: ObjectStore, name: Symbol) -> LogWitnessStored { match store_node(store: store, n: log_witness_atom(name: name)) { Stored { store: next, node: node } => - LogWitnessStored { store: next, identity: NodeTargetResolved(node) } + LogWitnessStored { store: next, identity: node_target_of(reference: node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - LogWitnessStored { store: store, identity: NodeTargetUnresolved(identity) } + LogWitnessStored { store: store, identity: SemanticNodeTarget { locator: identity } } } } @@ -72,7 +70,7 @@ fn log_witness_empty(store: ObjectStore) -> RepositoryEnvelope { } } -fn log_witness_mint(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn log_witness_mint(repo: RepositoryEnvelope, root: SemanticNodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo diff --git a/dag/test/claim/scm/scm_merge_witness_test.dag b/dag/test/claim/scm/scm_merge_witness_test.dag index 914133eff77..c27fce561be 100644 --- a/dag/test/claim/scm/scm_merge_witness_test.dag +++ b/dag/test/claim/scm/scm_merge_witness_test.dag @@ -22,9 +22,7 @@ import gunbc.scm.object_store { SourceStoreOutcome, SourceStored, SourceLocatorCollision, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, empty_store, store_node, store_authored_source, @@ -92,10 +90,10 @@ fn store_or_empty(outcome: StoreOutcome) -> ObjectStore { } } -fn root_or(outcome: StoreOutcome, fallback: ObjectId) -> NodeTarget { +fn root_or(outcome: StoreOutcome, fallback: ObjectId) -> SemanticNodeTarget { match outcome { - Stored { store: _, node: node } => NodeTargetResolved(node) - LocatorCollision { identity: _, existing: _, incoming: _ } => NodeTargetUnresolved(fallback) + Stored { store: _, node: node } => node_target_of(reference: node) + LocatorCollision { identity: _, existing: _, incoming: _ } => SemanticNodeTarget { locator: fallback } } } @@ -119,10 +117,10 @@ fn source_store_or_empty(outcome: SourceStoreOutcome) -> ObjectStore { } } -fn source_root_or(outcome: SourceStoreOutcome, fallback: ObjectId) -> NodeTarget { +fn source_root_or(outcome: SourceStoreOutcome, fallback: ObjectId) -> SemanticNodeTarget { match outcome { - SourceStored { store: _, source: source } => NodeTargetUnresolved(source.locator) - SourceLocatorCollision { identity: _, existing: _, incoming: _ } => NodeTargetUnresolved(fallback) + SourceStored { store: _, source: source } => SemanticNodeTarget { locator: source.locator } + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => SemanticNodeTarget { locator: fallback } } } @@ -731,19 +729,19 @@ test fn merge_a_target_root_that_is_a_node_does_not_refuse() -> Bool { fn merge_witness_role_over_a_file_fixture() -> Fixture { match store_authored_source(store: empty_store(), text: "not a program\n") { SourceLocatorCollision { identity: _, existing: _, incoming: _ } => - Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(fallback_id()) } } + Fixture { store: empty_store(), target: Commit { root: SemanticNodeTarget { locator: fallback_id() } } } SourceStored { store: with_file, source: file } => match insert_from_structure( store: with_file, kind: TypeNode { connective: Conj }, children: [ - StoredEdge { label: Named { name: ^s }, target: NodeTargetUnresolved(file.locator) }, + StoredEdge { label: Named { name: ^s }, target: SemanticNodeTarget { locator: file.locator } }, ], ) { LocatorCollision { identity: i, existing: _, incoming: _ } => - Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Fixture { store: empty_store(), target: Commit { root: SemanticNodeTarget { locator: i } } } Stored { store: assembled, node: root } => - Fixture { store: assembled, target: Commit { root: NodeTargetResolved(root) } } + Fixture { store: assembled, target: Commit { root: node_target_of(reference: root) } } } } } @@ -760,19 +758,19 @@ test fn merge_a_role_whose_value_is_a_file_refuses_as_authored_source() -> Bool fn merge_witness_role_over_a_node_fixture() -> Fixture { match store_node(store: empty_store(), n: merge_witness_leaf(name: ^child)) { LocatorCollision { identity: i, existing: _, incoming: _ } => - Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Fixture { store: empty_store(), target: Commit { root: SemanticNodeTarget { locator: i } } } Stored { store: with_child, node: child } => match insert_from_structure( store: with_child, kind: TypeNode { connective: Conj }, children: [ - StoredEdge { label: Named { name: ^s }, target: NodeTargetUnresolved(child.locator) }, + StoredEdge { label: Named { name: ^s }, target: SemanticNodeTarget { locator: child.locator } }, ], ) { LocatorCollision { identity: i, existing: _, incoming: _ } => - Fixture { store: empty_store(), target: Commit { root: NodeTargetUnresolved(i) } } + Fixture { store: empty_store(), target: Commit { root: SemanticNodeTarget { locator: i } } } Stored { store: assembled, node: root } => - Fixture { store: assembled, target: Commit { root: NodeTargetResolved(root) } } + Fixture { store: assembled, target: Commit { root: node_target_of(reference: root) } } } } } diff --git a/dag/test/claim/scm/scm_object_store_witness_test.dag b/dag/test/claim/scm/scm_object_store_witness_test.dag index 40e9aa0e713..cd85fe6472d 100644 --- a/dag/test/claim/scm/scm_object_store_witness_test.dag +++ b/dag/test/claim/scm/scm_object_store_witness_test.dag @@ -46,9 +46,7 @@ import gunbc.scm.object_store { ObjectId, ObjectRecord, StoredEdge, - NodeTarget, - NodeTargetResolved, - NodeTargetUnresolved, + SemanticNodeTarget, node_target_locator, ObjectStore, NodeRecordFound, @@ -168,25 +166,25 @@ fn structurally_same(left: Node, right: Node) -> Bool { // 64-bit value. This carrier keeps the claims readable while making a collision VISIBLE rather than // absorbed: `collided` is asserted false across the whole fixture set by its own claim below, so a // fixture that started colliding would surface instead of quietly reshaping every other claim. -// THE FIXTURE CARRIES A NodeTarget, AND THE COLLISION ARM IS WHY IT IS NOT THE BRANDED REFERENCE. +// THE FIXTURE CARRIES A SemanticNodeTarget, AND THE COLLISION ARM IS WHY IT IS NOT THE BRANDED REFERENCE. // // A SemanticNodeObjectRef is `sole_constructor` -- only the store mints one -- so this arm, which // has no stored node, has nothing to put there and no way to fabricate one. That refusal is the -// wall working: the fixture cannot assert a node reference it never obtained. NodeTarget is the +// wall working: the fixture cannot assert a node reference it never obtained. SemanticNodeTarget is the // type that CAN say what is true in both arms -- resolved to a node, or a bare locator whose kind // nothing established -- and it is exactly what commit_of_root takes. type Put { store: ObjectStore - identity: NodeTarget + identity: SemanticNodeTarget collided: Bool } fn put_node(store: ObjectStore, n: Node) -> Put { match store_node(store: store, n: n) { Stored { store: s, node: i } => - Put { store: s, identity: NodeTargetResolved(i), collided: false } + Put { store: s, identity: node_target_of(reference: i), collided: false } LocatorCollision { identity: i, existing: _, incoming: _ } => - Put { store: empty_store(), identity: NodeTargetUnresolved(i), collided: true } + Put { store: empty_store(), identity: SemanticNodeTarget { locator: i }, collided: true } } } diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 39e162deae6..3d0a1b9e642 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -29,12 +29,12 @@ import v2.std.node { import gunbc.scm.object_store { ObjectId, ObjectStore, StoreOutcome, Stored, LocatorCollision, SourceStored, SourceLocatorCollision, - NodeTarget, NodeTargetResolved, NodeTargetUnresolved, node_target_locator, + SemanticNodeTarget, node_target_locator, empty_store, object_id_eq, store_node, store_object_count, CopyOutcome, Copied, CopySourceAbsent, CopyLocatorCollision, copy_object, store_authored_source, } -import gunbc.scm.commit_closure { uncontained_targets } +import gunbc.scm.commit_closure { table_unresolved_identities } import gunbc.scm.ancestry { CommitAncestry, RootCommit, DescendsFrom, RepositoryCommitRef } import gunbc.scm.repository_envelope { RepositoryEnvelope, RepositoryCommit, @@ -125,13 +125,13 @@ fn scm_env_program_two() -> Node { ) } -type ScmEnvPut { store: ObjectStore, identity: NodeTarget } +type ScmEnvPut { store: ObjectStore, identity: SemanticNodeTarget } fn scm_env_put(store: ObjectStore, n: Node) -> ScmEnvPut { match store_node(store: store, n: n) { - Stored { store: s, node: n } => ScmEnvPut { store: s, identity: NodeTargetResolved(n) } + Stored { store: s, node: n } => ScmEnvPut { store: s, identity: node_target_of(reference: n) } LocatorCollision { identity: i, existing: _, incoming: _ } => - ScmEnvPut { store: store, identity: NodeTargetUnresolved(i) } + ScmEnvPut { store: store, identity: SemanticNodeTarget { locator: i } } } } @@ -148,7 +148,7 @@ fn scm_env_repository_with_store(store: ObjectStore) -> RepositoryEnvelope { } } -fn scm_env_minted_or(repo: RepositoryEnvelope, root: NodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { +fn scm_env_minted_or(repo: RepositoryEnvelope, root: SemanticNodeTarget, message: String, parent: RepositoryCommitRef?) -> RepositoryEnvelope { match mint_repository_commit(repository: repo, root: root, message: message, parent: parent) { RepositoryCommitMinted { repository: minted, reference: _ } => minted RepositoryCommitMintAllocatorInvalid { next_ordinal: _ } => repo @@ -660,7 +660,7 @@ fn scm_env_named_target_is_uncontained(outcome: RepositoryEncodeOutcome, store: RepositoryEncodeRefused { cause: c } => match c { RepositoryEncodeUncontainedTarget { target: t } => - fold(uncontained_targets(store: store), init: false, f: fn(acc, id) { + fold(table_unresolved_identities(store: store), init: false, f: fn(acc, id) { acc || object_id_eq(left: id, right: t) }) RepositoryCommitRootNotInStore { root: _ } => false @@ -1205,7 +1205,7 @@ test fn env_a_commit_root_that_is_a_source_file_refuses_as_authored_source() -> SourceStored { store: store, source: file } => match mint_repository_commit( repository: scm_env_repository_with_store(store: store), - root: NodeTargetUnresolved(file.locator), + root: SemanticNodeTarget { locator: file.locator }, message: "root is a file", parent: none, ) { diff --git a/dag/test/claim/scm/scm_status_witness_test.dag b/dag/test/claim/scm/scm_status_witness_test.dag index 7fa43576605..ce6927d1d28 100644 --- a/dag/test/claim/scm/scm_status_witness_test.dag +++ b/dag/test/claim/scm/scm_status_witness_test.dag @@ -6,7 +6,7 @@ import v2.std.node { Node, Symbol, TypeNode, Atom, node_synthetic } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import gunbc.scm.object_store { ObjectStore, ObjectId, empty_store, store_node, Stored, LocatorCollision, - NodeTarget, NodeTargetResolved, NodeTargetUnresolved, + SemanticNodeTarget, } import gunbc.scm.ancestry { RepositoryCommitRef } import gunbc.scm.repository_envelope { @@ -42,14 +42,14 @@ fn status_witness_atom(name: Symbol) -> Node { node_synthetic(kind: TypeNode { connective: Atom { identity: name } }, children: []) } -type StatusWitnessStored { store: ObjectStore, identity: NodeTarget } +type StatusWitnessStored { store: ObjectStore, identity: SemanticNodeTarget } fn status_witness_stored(name: Symbol) -> StatusWitnessStored { match store_node(store: empty_store(), n: status_witness_atom(name: name)) { Stored { store: store, node: node } => - StatusWitnessStored { store: store, identity: NodeTargetResolved(node) } + StatusWitnessStored { store: store, identity: node_target_of(reference: node) } LocatorCollision { identity: identity, existing: _, incoming: _ } => - StatusWitnessStored { store: empty_store(), identity: NodeTargetUnresolved(identity) } + StatusWitnessStored { store: empty_store(), identity: SemanticNodeTarget { locator: identity } } } } From 8cf125c946e6a2798fb9aaf97b5eeb46026428ac Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 19:32:53 +0000 Subject: [PATCH 13/25] Canonicalize on the other route too, and stop fabricating a collision object_store already records why the stored record must be the canonical one: content_hash_of_children SORTS named edges, so keying on the canonical identity while persisting the authored order stores an arbitrary first-observed representative of an equivalence class. store_node does that. insert_from_ structure, the other public way to make a record, did not -- it derived the locator from the canonicalized children and then persisted the children AS SUPPLIED. The consequence was a FABRICATED REFUSAL, which is why it survived: writing [alpha, beta] and then [beta, alpha] derives ONE locator, so the second write found the first record, records_equal compared labeled children in supplied order, they differed, and insert_admission answered LocatorCollision. Nothing had collided. The store refused a write it should have accepted, and every existing claim stayed green because none of them wrote one object two ways. Found by external review reading the identity rule against the persistence, then executed here rather than argued: re_writing_the_same_bindings_in_another_order_is_idempotent FAIL -> PASS swapping_which_target_each_label_names_is_a_different_object PASS throughout The control is the half that matters: without it the claim is satisfied by collapsing genuinely different label-to-target associations into one object, which would trade a fabricated refusal for a fabricated equality. The canonicalization reuses std's canonicalize_labeled_for_content_hash rather than sorting locally. The ordering rule belongs to the identity authority, and a second sort in this module would be that rule written twice, free to drift from the one the hash actually uses. SCM suite 309 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/object_store.dag | 36 +++++- .../scm/scm_commit_closure_witness_test.dag | 116 +++++++++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index 0129c1e18a1..a3ea76f4260 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -17,6 +17,7 @@ import v2.std.node { content_hash, node_synthetic, canonicalize_node_for_content_hash, + canonicalize_labeled_for_content_hash, LabeledChildHash, content_hash_of_children, } @@ -288,6 +289,12 @@ fn labeled_child_of_stored(stored: StoredEdge) -> LabeledChildHash { LabeledChildHash { label: stored.label, hash: node_target_locator(target: stored.target) } } +// A NAMED TOP-LEVEL PROJECTION rather than an inline lambda, for the reason std.node records beside +// its own: a lambda in that generic argument position does not infer its parameter type. +fn stored_edge_label_of(e: StoredEdge) -> EdgeLabel { + e.label +} + fn labeled_children_of_stored(children: List) -> List { map(children, s => labeled_child_of_stored(stored: s)) } @@ -698,22 +705,47 @@ fn store_node(store: ObjectStore, n: Node) -> StoreOutcome { // canonicalize_node_for_content_hash, which is the fork the identity comment above refuses. // dissolve-on: canonicalization exposed over labeled children in v2.std.node, callable without a // Node in hand; this function then routes through it. +// THE STORED CHILDREN ARE CANONICAL HERE TOO, and this route is where that had been missed. +// +// store_node already canonicalizes before persisting, and the note above it explains why: the +// identity rule SORTS named edges, so keying on the canonical identity while persisting the authored +// order stores an arbitrary first-observed representative of an equivalence class. This function is +// the OTHER public way to make a record, and it derived the locator from the canonicalized children +// and then persisted `children` AS SUPPLIED -- the same defect surviving on the sibling route. +// +// The consequence was a FABRICATED REFUSAL rather than a missed one, which is why it went unnoticed: +// writing [alpha, beta] and then [beta, alpha] derives ONE locator, so the second write found the +// first record, records_equal compared the labeled children in supplied order, they differed, and +// insert_admission reported a LocatorCollision for a store being asked to do something ordinary. +// Nothing had collided. Executed by test.claim.scm_commit_closure_witness +// `re_writing_the_same_bindings_in_another_order_is_idempotent`, with +// `swapping_which_target_each_label_names_is_a_different_object` as the control that keeps this from +// being satisfied by collapsing genuinely different label-to-target associations. +// +// The canonicalization reuses std's own canonicalize_labeled_for_content_hash rather than sorting +// here: the ordering rule belongs to the identity authority, and a second sort in this module would +// be the same rule written twice, free to drift from the one the hash actually uses. fn insert_from_structure( store: ObjectStore, kind: NodeKind, children: List ) -> StoreOutcome { + let canonical = canonicalize_labeled_for_content_hash( + kind: kind, + items: children, + label_of: stored_edge_label_of, + ) insert_derived( store: store, record: ObjectRecord { identity: SemanticNodeObjectRef { locator: content_hash_of_children( kind: kind, - children: labeled_children_of_stored(children: children), + children: labeled_children_of_stored(children: canonical), ), }, kind: kind, - children: children, + children: canonical, }, ) } diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index b8f864271fa..cb1ea8e1f82 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -13,7 +13,7 @@ module test.claim.scm_commit_closure_witness // merely non-empty. `a_grafted_root_names_exactly_the_child_it_lacks` is the test that decides it. import std.types { Bool, Int, String, List, list_length } -import v2.std.node { Node, Edge, Named, Conj, Atom, TypeNode, node_synthetic } +import v2.std.node { Node, Edge, Symbol, Named, Conj, Atom, TypeNode, node_synthetic } import gunbc.scm.object_store { ObjectId, ObjectStore, @@ -681,3 +681,117 @@ test fn a_present_child_round_trips_as_a_complete_closure() -> Bool { } } } + +// ------------------------------------------------------------------------------------------------ +// TWO AUTHORED ORDERS OF THE SAME NAMED BINDINGS ARE ONE OBJECT, AND RE-WRITING ONE IS IDEMPOTENT. +// +// content_hash_of_children canonicalizes -- canonicalize_node_for_content_hash SORTS named edges -- +// so [alpha, beta] and [beta, alpha] over the same targets derive ONE locator. object_store already +// records this for store_node, which canonicalizes before persisting so that the stored record is the +// canonical one rather than an arbitrary first-observed representative. +// +// insert_from_structure is the OTHER public route to a record and it does not do that: it derives the +// locator from the canonicalized children and then persists the children AS SUPPLIED. So the second +// write finds a record under the same locator, records_equal compares the labeled children in +// supplied order, they differ, and insert_admission reports a LocatorCollision -- for a store being +// asked to do something perfectly ordinary. The refusal is fabricated: nothing collided. +// +// This is the same class the note above store_node describes, surviving on the sibling route, and it +// is a fabricated refusal rather than a missed one -- the store refuses a write it should accept. +fn permutation_alpha_child() -> Node { + node_synthetic(kind: TypeNode { connective: Atom { identity: ^permutation_alpha } }, children: []) +} + +fn permutation_beta_child() -> Node { + node_synthetic(kind: TypeNode { connective: Atom { identity: ^permutation_beta } }, children: []) +} + +type PermutationFixture { store: ObjectStore alpha: SemanticNodeTarget beta: SemanticNodeTarget ok: Bool } + +fn permutation_fixture() -> PermutationFixture { + let unusable = SemanticNodeTarget { locator: fallback_id() } + match store_node(store: empty_store(), n: permutation_alpha_child()) { + LocatorCollision { identity: _, existing: _, incoming: _ } => + PermutationFixture { store: empty_store(), alpha: unusable, beta: unusable, ok: false } + Stored { store: s1, node: alpha } => + match store_node(store: s1, n: permutation_beta_child()) { + LocatorCollision { identity: _, existing: _, incoming: _ } => + PermutationFixture { store: empty_store(), alpha: unusable, beta: unusable, ok: false } + Stored { store: s2, node: beta } => + PermutationFixture { + store: s2, + alpha: node_target_of(reference: alpha), + beta: node_target_of(reference: beta), + ok: true, + } + } + } +} + +fn insert_two_named(store: ObjectStore, first_label: Symbol, first: SemanticNodeTarget, second_label: Symbol, second: SemanticNodeTarget) -> StoreOutcome { + insert_from_structure( + store: store, + kind: TypeNode { connective: Conj }, + children: [ + StoredEdge { label: Named { name: first_label }, target: first }, + StoredEdge { label: Named { name: second_label }, target: second }, + ], + ) +} + +// THE DISCRIMINATING RED. One store, two authored orders, one canonical object. The second write must +// be idempotent, not a collision. +test fn re_writing_the_same_bindings_in_another_order_is_idempotent() -> Bool { + let fx = permutation_fixture() + fx.ok + && match insert_two_named( + store: fx.store, + first_label: ^alpha, + first: fx.alpha, + second_label: ^beta, + second: fx.beta, + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: once, node: first_ref } => + match insert_two_named( + store: once, + first_label: ^beta, + first: fx.beta, + second_label: ^alpha, + second: fx.alpha, + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, node: second_ref } => + object_id_eq(left: first_ref.locator, right: second_ref.locator) + } + } +} + +// THE CONTROL, without which the claim above is satisfied by a store that never refuses. Different +// label-to-target ASSOCIATIONS are a genuinely different object, so they derive a different locator +// and both records stand -- this is not a permutation and must not be collapsed into one. +test fn swapping_which_target_each_label_names_is_a_different_object() -> Bool { + let fx = permutation_fixture() + fx.ok + && match insert_two_named( + store: fx.store, + first_label: ^alpha, + first: fx.alpha, + second_label: ^beta, + second: fx.beta, + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: once, node: first_ref } => + match insert_two_named( + store: once, + first_label: ^alpha, + first: fx.beta, + second_label: ^beta, + second: fx.alpha, + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, node: second_ref } => + !object_id_eq(left: first_ref.locator, right: second_ref.locator) + } + } +} From b8682d7b20b934982df553d69360a61cdf7a6fcf Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 19:52:04 +0000 Subject: [PATCH 14/25] Derive dependency order instead of trusting insertion order store_records_in_dependency_order returned store.objects, and the note beside it argued that insertion order IS dependency order because store_node recurses into a target before appending its parent. True for store_node, false for the graft route: copy_object appends one record without its children, so copying a parent and then its child leaves the parent EARLIER than the object it references. Completeness never noticed -- both objects are present. The encoder did. It assigns positions in this order, so it reached the parent before the child had one and spelled the target UNCONTAINED while the document carried that very object a few entries later. That contradicts the declared wire meaning of the arm and let a complete closure emit a partial spelling. A post-order walk emits every referenced record before its referent, for any insertion history. The store is acyclic by construction -- a locator is derived from content that already includes its children's locators, so a cycle would have to hash itself -- which is what makes the walk terminate without a separate descent argument. THE FIRST VERSION OF THE CLAIM WAS NOT DISCRIMINATING, and the comment says so because the failure mode is instructive. Encoding, round-tripping through text and requiring the reloaded closure to be complete PASSES ON THE DEFECT: decode re-stores every object in the table, so the child arrives whichever spelling its parent used. The defect is in the BYTES, and an assertion that reads only the rebuilt store cannot see it. The claim now compares IMAGES. The grafted-then-completed store and the naturally-built store are the same closure, so they must serialize to the same document; under the insertion-order encoder they did not. one_closure_has_one_image_however_its_store_was_assembled FAIL -> PASS a_genuinely_partial_store_does_not_encode_as_the_complete_one PASS throughout The control is load-bearing: without it the claim is satisfied by an encoder that never spells a target uncontained at all, which would delete the partial-closure format this branch exists to add. ON COST, since DESIGN section 6 refuses "n is small here": emitted_holds is a linear scan, so the walk is quadratic in object count. That is the same shape find_object already has -- the carrier is a List -- and is bounded by the same dissolve-on already recorded for that carrier. A keyed collection retires both at once; fixing it here alone would leave the store's own lookup quadratic while adding a second index to maintain. SCM suite 311 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/object_store.dag | 70 ++++++++++++++- .../scm/scm_commit_closure_witness_test.dag | 87 +++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index a3ea76f4260..3b74e03430c 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -315,8 +315,76 @@ fn store_object_count(store: ObjectStore) -> Int { // appends the parent, so a record is appended only after every record it references. Persistence // depends on that: an image whose edges point at EARLIER positions can be decoded in one forward // pass with no forward references to resolve and no identities to trust. +// DEPENDENCY ORDER IS DERIVED, NOT ASSUMED FROM INSERTION ORDER. +// +// This used to return store.objects, and the note above argued that insertion order IS dependency +// order because store_node recurses into a target before appending its parent. That is true for +// store_node and FALSE for the graft route: copy_object appends one record without its children, so +// copying a parent and then its child leaves the parent EARLIER than the object it references. +// +// Completeness never noticed, because both objects are present. The encoder did: it assigns positions +// in this order, so it reached the parent before the child had one and spelled the target UNCONTAINED +// while the document went on to carry that very object a few entries later -- contradicting the +// declared wire meaning of uncontained and letting a complete closure emit a partial spelling. +// +// A post-order walk emits every referenced record before the record referencing it, for any insertion +// history. The store is acyclic by construction -- a locator is derived from content that already +// includes its children's locators, so a cycle would have to hash itself -- which is what makes the +// walk terminate without a separate descent argument. +// +// ON COST, because DESIGN section 6 refuses "n is small here": emitted_holds is a linear scan, so +// this is quadratic in the object count. That is the SAME shape find_object already has, since the +// carrier is a List, and it is bounded by the same dissolve-on already recorded above for that +// carrier -- a keyed collection retires both at once. Fixing it here alone would leave the store's +// own lookup quadratic while adding a second index to maintain. +type OrderAcc { + emitted: List +} + +fn emitted_holds(acc: OrderAcc, identity: ObjectId) -> Bool { + fold( + acc.emitted, + init: false, + f: fn(seen, object) { + seen || object_id_eq(left: scm_object_identity(object: object), right: identity) + } + ) +} + +fn emit_in_dependency_order(store: ObjectStore, acc: OrderAcc, object: ScmObject) -> OrderAcc { + if emitted_holds(acc: acc, identity: scm_object_identity(object: object)) { + acc + } else { + let after_children = match object { + AuthoredSourceObject(_) => acc + SemanticNodeObject(record) => + fold( + record.children, + init: acc, + f: fn(inner, child) { + emit_dependency(store: store, acc: inner, identity: child.target.locator) + } + ) + } + OrderAcc { emitted: concat(after_children.emitted, [object]) } + } +} + +// A target whose object this store does not hold contributes nothing -- it is exactly the uncontained +// case, and the encoder spells it from the digest rather than from a position. +fn emit_dependency(store: ObjectStore, acc: OrderAcc, identity: ObjectId) -> OrderAcc { + match find_object(store: store, identity: identity) { + Absent => acc + Present { value: object } => emit_in_dependency_order(store: store, acc: acc, object: object) + } +} + fn store_records_in_dependency_order(store: ObjectStore) -> List { - store.objects + fold( + store.objects, + init: OrderAcc { emitted: [] }, + f: fn(acc, object) { emit_in_dependency_order(store: store, acc: acc, object: object) } + ).emitted } fn empty_store() -> ObjectStore { diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index cb1ea8e1f82..5c307401ce3 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -795,3 +795,90 @@ test fn swapping_which_target_each_label_names_is_a_different_object() -> Bool { } } } + +// ------------------------------------------------------------------------------------------------ +// A LATE GRAFT COMPLETES THE CLOSURE, AND THE DOCUMENT MUST SAY SO. +// +// store_records_in_dependency_order returns insertion order, and object_store's note beside it argues +// that order is dependency order because store_node recurses into a target before appending its +// parent. That is true for store_node and FALSE for the graft route: copy_object appends one record +// without its children, so copying the parent and then the child leaves the parent EARLIER than the +// child it references. +// +// Completeness does not care -- both objects are present, so the closure is complete. The encoder +// does: it assigns positions in that order, so when it encodes the parent the child has no position +// yet and the target is emitted as UNCONTAINED, naming a digest the document goes on to carry a few +// entries later. That contradicts the declared wire meaning of uncontained -- "this document does NOT +// carry it" -- and lets a complete closure emit a partial spelling. +// +// The pair is the claim: parent alone must encode uncontained, and parent-then-child must encode +// contained. Without the first cell the second is satisfied by an encoder that never emits +// uncontained at all, which would break the partial-closure format this branch exists to add. +fn build_grafted_then_completed_store() -> ObjectStore { + let full = build_full_store() + match copy_object( + from: full.store, + to: empty_store(), + identity: node_target_locator(target: full.root), + ) { + CopySourceAbsent { identity: _ } => empty_store() + CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() + Copied { store: with_parent, reference: _ } => + match copy_object(from: full.store, to: with_parent, identity: full.child) { + CopySourceAbsent { identity: _ } => empty_store() + CopyLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() + Copied { store: with_both, reference: _ } => with_both + } + } +} + +// THE DISCRIMINATING RED, AND THE FIRST VERSION OF IT WAS NOT. +// +// The obvious assertion -- encode, round-trip through text, and require the reloaded closure to be +// complete -- PASSES ON THE DEFECT, and finding that out is why this comment exists rather than a +// claim that merely looked thorough. Decode re-stores every object in the table, so the child arrives +// either way and the reloaded closure is complete whichever spelling its parent used. The defect is +// in the BYTES and an assertion that only reads the rebuilt store cannot see it. +// +// So the claim compares IMAGES. The grafted-then-completed store and the naturally-built store are +// the SAME closure -- same objects, same root, same identities -- so they must serialize to the same +// document. Under the insertion-order encoder they do not: one spells the child target contained and +// the other spells it uncontained, and the byte comparison is what refuses that. +test fn one_closure_has_one_image_however_its_store_was_assembled() -> Bool { + let full = build_full_store() + let completed = build_grafted_then_completed_store() + let closure = CommitClosure { store: completed, root: full.root } + closure_is_complete(closure: closure) + && match encode_complete_closure_document(closure: closure) { + ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: grafted_image } => + match encode_complete_closure_document( + closure: CommitClosure { store: full.store, root: full.root } + ) { + ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: natural_image } => + serialize_json(v: grafted_image) == serialize_json(v: natural_image) + } + } +} + +// THE CONTROL. Without it the claim above is satisfied by an encoder that never spells a target +// uncontained at all -- which would delete the partial-closure format this branch exists to add. The +// parent alone genuinely does not carry its child, so its image must differ from the complete one. +test fn a_genuinely_partial_store_does_not_encode_as_the_complete_one() -> Bool { + let full = build_full_store() + let grafted = build_grafted_store() + match admitted_for(store: grafted, root: full.root) { + ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false + PartialClosureAdmitted { admitted: a } => + match encode_complete_closure_document( + closure: CommitClosure { store: full.store, root: full.root } + ) { + ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: natural_image } => + serialize_json(v: encode_admitted_partial_closure_document(admitted: a)) + != serialize_json(v: natural_image) + } + } +} From e710a35cd5e23a55fe468627c3f1a5dd06459806 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 20:10:27 +0000 Subject: [PATCH 15/25] Delete the two node-presence predicates the census replaced Review 58294 found store_contains_node has no caller anywhere in the tree. That is correct and it is mine: this PR added it, it is absent from main, and the census split then removed the need for it without removing it. The same deletion catches a second one the finding did not name. store_holds_node_at's ONLY caller was store_contains_node, so with that gone it is dead too. Deleting just the reported symbol would have left a dead function whose sole consumer had been deleted in the same commit -- fixing the finding rather than the fact. Both are superseded by resolve_node_target, which answers the same question three ways instead of two. That is the whole point of the census: a Bool at a node position cannot separate "nothing is here" from "a file is here", so nothing should be asking for one. I do not accept the rule the finding cites. There is no corpus-wide prohibition on Boolean projections over a coproduct in DESIGN.md -- the same claim was made in review 58182 and answered there with a census of 2,523 such functions across 908 files. What makes these two deletable is that they are UNUSED, which is DESIGN section 2: unnecessary work is redundant work, and dead API surface on a sole_constructor carrier is surface a later caller can reach for. SCM suite 311 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/object_store.dag | 17 ----------------- .../scm/scm_commit_closure_witness_test.dag | 2 +- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index 3b74e03430c..e5b9c3d3345 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -428,23 +428,6 @@ fn find_node_record(store: ObjectStore, identity: ObjectId) -> NodeRecordLookup } } -// PRESENCE AT A NODE POSITION, WHICH IS NOT PRESENCE AT A LOCATOR. Completeness over a closure asks -// whether every child edge RESOLVES TO A PROGRAM in this store; a file sitting under that locator -// answers the locator question yes and the closure question no. Asking store_contains here counted a -// file as a contained child, which is one of the consequences external review derived from the -// untyped-locator defect. The brand carries the requirement; this carries the store-relative answer. -fn store_contains_node(store: ObjectStore, node: SemanticNodeObjectRef) -> Bool { - store_holds_node_at(store: store, locator: node.locator) -} - -fn store_holds_node_at(store: ObjectStore, locator: ObjectId) -> Bool { - match find_node_record(store: store, identity: locator) { - NodeRecordFound(_) => true - NodeRecordAbsent { identity: _ } => false - NodeRecordIsAuthoredSource { identity: _ } => false - } -} - // A CHILD EDGE IS RESOLVED THREE WAYS, NEVER TWO. // // This replaces store_contains_node_target, which answered Bool. That Bool is the whole defect the diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index 5c307401ce3..b379dfba155 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -549,7 +549,7 @@ test fn a_non_string_at_is_refused_against_at() -> Bool { // // The three cells below differ by ONE FACT -- what, if anything, sits at the child's locator -- and // they exist because the branch's own carrier cannot tell two of them apart. store_contains_node_target -// asks store_holds_node_at, which asks for a SEMANTIC NODE, so an authored source at L answers false; +// resolved through find_node_record, which asks for a SEMANTIC NODE, so a file at L is not a node; // uncontained_in_record reads every false as a fetchable missing locator and has no wrong-kind arm. // // THE ASSERTION IS A PROHIBITION, NOT A DESCRIPTION, and that distinction is the whole design of this From 651c9911d1d991822cb2d26b6f5aff730dc83f1a Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 20:18:46 +0000 Subject: [PATCH 16/25] Name the repository codec for the tag it implements, and freeze a real v1 document TWO ITEMS FROM THE SCM REVIEWER'S BAR, and they are the same seam from opposite sides: what this build CALLS its repository format, and what it DOES with the format it replaced. repository_envelope_format_tag has read "gunbc-scm-repository-v2" since the protocol cut, while the dispatch arm was RepositoryFormatV1 and the codec functions were encode_repository_v1 and decode_repository_v1. That is the meaning fork DESIGN section 3 names: one spelling over two materially different contracts, with the wrong one standing where a reader looks first. Nothing about the schema changes here -- the names were lying about which schema they were. The frozen-predecessor claim needed a REAL document. The existing unknown-format claim uses "gunbc-scm-repository-v99", a version that never existed: it proves the dispatch refuses a tag it does not know, which is a different question from whether this build handles the format it actually superseded. This branch moved the tag from -v1 to -v2, so every repository written before it is a real artifact a real caller can hand to this build today. The fixture is main's v1 document verbatim, carrying both v1 axes -- the schema tag and the fnv1a64-structural-canonical-v1 identity rule this branch also replaced. THE ASSERTION IS THE STANDING, NOT THE REFUSAL. Refusing is easy and the wrong refusal is worse than none: "these bytes are damaged" sends a reader hunting corruption in a perfectly intact file, while "I do not speak this protocol" names a version gap with a real remedy. A refusal-only assertion cannot separate them, because both arms refuse. Mutation receipt -- mapping RepositoryFormatUnrecognized to LoadDocumentMalformed: scm_rl_a_frozen_v1_document_is_a_protocol_gap_not_damage FAIL scm_rl_a_damaged_document_is_not_a_protocol_gap PASS (control held) The control is what stops a classifier that answers UnsupportedProtocol for everything from satisfying the claim. SCM suite 313 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/repository_envelope.dag | 24 +++++--- .../scm/scm_repository_load_witness_test.dag | 58 ++++++++++++++++++- .../frozen_repository_v1.json | 8 +++ 3 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 dag/test/fixture/scm_repository_load/frozen_repository_v1.json diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index b89acd2c7c5..0f0f2d757c1 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -484,10 +484,10 @@ fn encode_commit_records(acc: EncodeAcc, commits: List) -> Lis // exist, and no migration claim -- building a synthetic second version now would test an invented // migration rather than the one the product eventually needs. fn encode_repository(repo: RepositoryEnvelope) -> JsonValue { - encode_repository_v1(repo: repo) + encode_repository_v2(repo: repo) } -fn encode_repository_v1(repo: RepositoryEnvelope) -> JsonValue { +fn encode_repository_v2(repo: RepositoryEnvelope) -> JsonValue { let acc = encode_object_table(store: repo.store) json_object( members: [ @@ -1040,18 +1040,24 @@ fn decode_commit_members_after_reference( // DIFFERENT identities, so discovering the mismatch after the walk would mean reporting a // well-formed repository that silently is not the one that was stored. // FORMAT DISPATCH IS ITS OWN STEP, and it selects a decoder that understands ONE exact schema. The -// shape a second version must take is a second arm here calling decode_repository_v2 -- never the v1 -// body widened to admit v2's members, which would erase the reason the member sets are closed at all. +// shape a THIRD version must take is a third arm here calling decode_repository_v3 -- never the v2 +// body widened to admit v3's members, which would erase the reason the member sets are closed at all. +// +// THE ARMS AND THE CODEC FUNCTIONS ARE NAMED FOR THE TAG THEY ACTUALLY IMPLEMENT. They said v1 while +// repository_envelope_format_tag said v2, which is the meaning fork DESIGN section 3 names: one +// spelling over two materially different contracts, with the wrong one left standing where a reader +// looks first. Nothing about the schema changed in this rename -- the names were simply lying about +// which schema they were. // The identity-rule tag stays a separate axis for the same reason it is separate today: a schema-v2 // document may still carry the v1 node-identity rule, and an identity migration may leave the JSON // schema untouched. type RepositoryFormat - = RepositoryFormatV1 + = RepositoryFormatV2 | RepositoryFormatUnknown { found: String } fn repository_format_of(tag: String) -> RepositoryFormat { if tag == repository_envelope_format_tag { - RepositoryFormatV1 + RepositoryFormatV2 } else { RepositoryFormatUnknown { found: tag } } @@ -1064,15 +1070,15 @@ fn decode_repository(v: JsonValue) -> RepositoryDecodeOutcome { match repository_format_of(tag: format) { RepositoryFormatUnknown { found: f } => RepositoryDecodeRefused { cause: RepositoryFormatUnrecognized { found: f } } - RepositoryFormatV1 => decode_repository_v1(v: v) + RepositoryFormatV2 => decode_repository_v2(v: v) } _ => RepositoryDecodeRefused { cause: RepositoryMemberReadRefused { cause: MemberReadWrongShape { key: "format" } } } } } -// EXACTLY v1. Every member population below is v1's, and this body is frozen once a v1 document +// EXACTLY v2. Every member population below is v2's, and this body is frozen once a v2 document // exists anywhere. -fn decode_repository_v1(v: JsonValue) -> RepositoryDecodeOutcome { +fn decode_repository_v2(v: JsonValue) -> RepositoryDecodeOutcome { match read_string_member(v: v, key: "identity_rule") { MemberFailed { cause: c } => RepositoryDecodeRefused { cause: RepositoryMemberReadRefused { cause: c } } MemberString { value: rule } => diff --git a/dag/test/claim/scm/scm_repository_load_witness_test.dag b/dag/test/claim/scm/scm_repository_load_witness_test.dag index f84355b9f7b..7b693ae5c67 100644 --- a/dag/test/claim/scm/scm_repository_load_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_load_witness_test.dag @@ -30,6 +30,12 @@ import gunbc.scm.repository_load { import gunbc.scm.repository_envelope { RepositoryDecodeRefusal, RepositoryFormatUnrecognized, + repository_envelope_load_standing, +} +import gunbc.scm.load_standing { + LoadStanding, + LoadUnsupportedProtocol, + LoadDocumentMalformed, } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -37,6 +43,7 @@ data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly data scm_rl_empty_repository_path: String = "dag/test/fixture/scm_repository_load/empty_repository.json" data scm_rl_malformed_document_path: String = "dag/test/fixture/scm_repository_load/malformed_document.json" data scm_rl_unknown_format_path: String = "dag/test/fixture/scm_repository_load/unknown_format.json" +data scm_rl_frozen_v1_path: String = "dag/test/fixture/scm_repository_load/frozen_repository_v1.json" // UNDER THE CHECKOUT ROOT AND NEVER CREATED. See the header: the location is the claim. data scm_rl_absent_path: String = "dag/test/fixture/scm_repository_load/this_file_is_never_created.json" @@ -79,9 +86,58 @@ test fn scm_rl_a_parseable_non_repository_is_refused_by_schema() -> Bool { } } +// A REAL FROZEN PREDECESSOR DOCUMENT, NOT AN INVENTED TAG. +// +// The claim above uses "gunbc-scm-repository-v99", a version that never existed. That proves the +// dispatch refuses a tag it does not know; it does NOT prove this build handles the format it +// actually superseded. Those are different questions, and only the second one has a document in the +// world -- this branch changed the tag from gunbc-scm-repository-v1 to -v2, so every repository +// written before it is a real artifact that a real caller can hand to this build today. +// +// The fixture is the v1 document verbatim from origin/main, carrying both v1 axes: the schema tag +// and the fnv1a64-structural-canonical-v1 identity rule this branch also replaced. +// +// THE ASSERTION IS THE STANDING, NOT MERELY THE REFUSAL, and that is the whole point. Refusing is +// easy and the wrong refusal is worse than none: "these bytes are damaged" sends a reader to look +// for corruption in a file that is perfectly intact, while "I do not speak this protocol" names a +// version gap with a real remedy. A refusal-only assertion cannot tell those apart, because both +// arms refuse. +test fn scm_rl_a_frozen_v1_document_is_a_protocol_gap_not_damage() -> Bool { + match load_repository(path: scm_rl_frozen_v1_path) { + RepositoryLoadRefused { cause: RepositoryDocumentRefused { path: p, cause: c } } => + p == scm_rl_frozen_v1_path + && match c { + RepositoryFormatUnrecognized { found: f } => f == "gunbc-scm-repository-v1" + _ => false + } + && match repository_envelope_load_standing(cause: c) { + LoadUnsupportedProtocol => true + _ => false + } + _ => false + } +} + +// THE CONTROL THE CLAIM ABOVE NEEDS. Without it, a classifier that answered LoadUnsupportedProtocol +// for everything would satisfy it. A genuinely damaged document must still classify as malformed. +test fn scm_rl_a_damaged_document_is_not_a_protocol_gap() -> Bool { + match load_repository(path: scm_rl_malformed_document_path) { + RepositoryLoadRefused { cause: RepositoryDocumentUnparseable { path: p, gap: _ } } => + p == scm_rl_malformed_document_path + RepositoryLoadRefused { cause: RepositoryDocumentRefused { path: _, cause: c } } => + match repository_envelope_load_standing(cause: c) { + LoadDocumentMalformed => true + _ => false + } + _ => false + } +} + test fn scm_repository_load_keystone_holds() -> Bool { scm_rl_a_well_formed_document_loads() && scm_rl_an_absent_file_is_unreadable_not_empty() && scm_rl_a_truncated_document_is_unparseable() && - scm_rl_a_parseable_non_repository_is_refused_by_schema() + scm_rl_a_parseable_non_repository_is_refused_by_schema() && + scm_rl_a_frozen_v1_document_is_a_protocol_gap_not_damage() && + scm_rl_a_damaged_document_is_not_a_protocol_gap() } diff --git a/dag/test/fixture/scm_repository_load/frozen_repository_v1.json b/dag/test/fixture/scm_repository_load/frozen_repository_v1.json new file mode 100644 index 00000000000..d15f2ae668a --- /dev/null +++ b/dag/test/fixture/scm_repository_load/frozen_repository_v1.json @@ -0,0 +1,8 @@ +{ + "format": "gunbc-scm-repository-v1", + "identity_rule": "fnv1a64-structural-canonical-v1", + "objects": [], + "next_commit_ordinal": 0, + "commits": [], + "checked_out": null +} From 54cd2e4d2a148055f648ddc28890642e1585a2fb Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 21:48:10 +0000 Subject: [PATCH 17/25] Read both census populations everywhere, not just the absent one The SCM reviewer re-anchored at 837115a and found the previous fix had MOVED the leak rather than closed it. They are right on all three counts. Splitting the census into absent and source_occupied is only worth what its consumers read. Three of them read the absent half alone: - encode_repository_checked guarded on an_uncontained_target and NOTHING else, so a requirement whose locator is occupied by an authored source walked through and got PUBLISHED. It now refuses first, with its own cause: RepositoryEncodeTargetIsAuthoredSource. The two refusals stay separate because their remedies are -- one says fetch an object that is not here, the other says something IS here and is the wrong kind, which no fetch repairs. - loaded_closure_standing answered LoadPartial for every not-complete closure. LoadPartial promises "fetch the named identities and this completes", which is FALSE for an occupied locator. LoadStanding grows LoadRequiresNodeAtAuthoredSource, and standing_loaded answers false for it: the document read fine, but what it asserts is unsatisfiable. - The raw encoder spells a source-occupied target uncontained, which is indistinguishable from absent on the wire. Both callers now refuse before reaching it. THE THIRD FINDING IS THE ONE I MOST NEEDED. My byte-equality claim -- one_closure_has_one_image_however_its_store_was_assembled -- catches a DISAGREEMENT between assembly routes but not a wrong spelling both routes share. Proven, not conceded: under an encoder that spells EVERY target uncontained, one_closure_has_one_image_however_its_store_was_assembled PASS a_complete_closure_spells_its_child_contained FAIL a_partial_closure_spells_its_missing_child_uncontained PASS Agreement is consistency, not correctness. The new claims read the arm out of the document through decode_target_reference -- the module's own reader, not a second parser written to grade the first. Item 3 also lacked its positional control. Canonicalizing named children is satisfied by sorting every child list unconditionally, which is wrong under Cardinality and Instantiation where PositionalEdges makes position the meaning. The canonicalizer already dispatches correctly on kind_edge_discipline; nothing executed it, so the rule was true and unwitnessed -- and an unwitnessed rule is one a later local sort can quietly replace. SCM suite 317 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure_json_v2.dag | 18 ++- dag/gunbc/scm/load_standing.dag | 15 ++ dag/gunbc/scm/repository_envelope.dag | 22 ++- dag/gunbc/scm/supersession.dag | 4 +- ...cm_commit_closure_json_v2_witness_test.dag | 2 + .../scm/scm_commit_closure_witness_test.dag | 140 +++++++++++++++++- .../scm/scm_load_standing_witness_test.dag | 3 +- .../scm_repository_envelope_witness_test.dag | 42 +++++- .../scm/scm_supersession_witness_test.dag | 3 +- 9 files changed, 239 insertions(+), 10 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 84e2d9581c6..bbb29539659 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -32,6 +32,8 @@ import gunbc.scm.commit_closure { AdmittedPartialCommitClosure, unresolved_identities, closure_is_complete, + closure_census, + ClosureCensus, } import gunbc.scm.object_table_json { ObjectTableDecodeRefusal, @@ -48,6 +50,7 @@ import gunbc.scm.load_standing { LoadStanding, LoadComplete, LoadPartial, + LoadRequiresNodeAtAuthoredSource, LoadUnsupportedProtocol, LoadDocumentMalformed, LoadIdentityCollision, @@ -1277,11 +1280,20 @@ fn closure_document_load_standing(cause: ClosureDocRefusal) -> LoadStanding { // predecessor format could not produce: a document whose uncontained references leave objects the // store does not hold loads correctly and is PARTIAL, which is a success with an obligation rather // than a failure. Completeness is derived from the objects, never a flag beside them. +// THREE ANSWERS, BECAUSE "NOT COMPLETE" IS TWO DIFFERENT FACTS. This asked closure_is_complete and +// answered LoadPartial for every false, which reported "fetch the named identities and this completes" +// for a closure whose requirement sits on a file -- unfetchable, because the locator is occupied. The +// census already separates the two populations; this now reads both instead of collapsing them. fn loaded_closure_standing(store: ObjectStore, root: SemanticNodeTarget) -> LoadStanding { - if closure_is_complete(closure: CommitClosure { store: store, root: root }) { - LoadComplete + let census = closure_census(closure: CommitClosure { store: store, root: root }) + if list_length(items: census.source_occupied) > 0 { + LoadRequiresNodeAtAuthoredSource } else { - LoadPartial + if list_length(items: census.absent) == 0 { + LoadComplete + } else { + LoadPartial + } } } diff --git a/dag/gunbc/scm/load_standing.dag b/dag/gunbc/scm/load_standing.dag index 5ba94a7cf3e..4e90d97c6fc 100644 --- a/dag/gunbc/scm/load_standing.dag +++ b/dag/gunbc/scm/load_standing.dag @@ -37,9 +37,17 @@ import std.types { Bool } // nameable as content addresses. So LoadPartial is a SUCCESS arm sitting beside three failure arms, // and conflating it with LoadDocumentMalformed would be the state-space conflation DESIGN names -- // "I could not read this" and "I read this, and it is a delta" have opposite remedies. +// A SIXTH ARM, AND IT IS NOT A KIND OF PARTIAL. LoadPartial says "I read this, and it is a delta -- +// fetch the named identities and it completes". That promise is FALSE for a requirement whose locator +// is occupied by an authored source: something is already there, it is the wrong kind, and no fetch +// repairs it. Answering LoadPartial sends a reader to fetch an object that is not missing, which is +// the same fabricated fetchable absence the object model closed at the record level -- re-entering +// here at the standing level, because a wrong-kind requirement and an absent one both fail to be +// complete and the two-valued question could not tell them apart. type LoadStanding = LoadComplete | LoadPartial + | LoadRequiresNodeAtAuthoredSource | LoadUnsupportedProtocol | LoadDocumentMalformed | LoadIdentityCollision @@ -79,10 +87,17 @@ type LoadStanding // Whether a standing represents a load that produced a usable closure at all. LoadPartial answers // TRUE: a delta is usable, it simply needs its missing objects supplied before checkout. +// +// LoadRequiresNodeAtAuthoredSource answers FALSE, and it is the arm that shows why this is not the +// same question as "did bytes arrive". The document read fine and its object table is intact; what it +// asserts is unsatisfiable -- a program is required where a file sits, and no supply step completes +// it. Grouping it with LoadPartial because both are "not complete" would tell a caller to fetch, and +// grouping it with the malformed arms would tell a caller the bytes were damaged. Neither is true. fn standing_loaded(standing: LoadStanding) -> Bool { match standing { LoadComplete => true LoadPartial => true + LoadRequiresNodeAtAuthoredSource => false LoadUnsupportedProtocol => false LoadDocumentMalformed => false LoadIdentityCollision => false diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 0f0f2d757c1..9fd48aca6b5 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -93,7 +93,7 @@ import gunbc.scm.object_store { empty_store, find_node_record, } -import gunbc.scm.commit_closure { table_unresolved_identities } +import gunbc.scm.commit_closure { table_unresolved_identities, table_source_occupied_identities } import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, DecodeAcc, @@ -243,6 +243,7 @@ type RepositoryEnvelope { // two sibling arms below never had: an identity left as text is one nobody can resolve back. type RepositoryEncodeRefusal = RepositoryEncodeUncontainedTarget { target: ObjectId } + | RepositoryEncodeTargetIsAuthoredSource { target: ObjectId } | RepositoryCommitRootNotInStore { root: ObjectId } | RepositoryCheckoutNotInCommits { selected: RepositoryCommitRef } | RepositoryCommitReferenceDuplicated { reference: RepositoryCommitRef } @@ -537,6 +538,20 @@ type RepositoryEncodeOutcome // is also the first. Pinning order here would additionally freeze an incidental traversal order into // the interface, which a later keyed or canonical representation of table_unresolved_identities should not // be forced to preserve. +// THE WRONG-KIND POPULATION IS A SEPARATE REFUSAL, NOT A SILENT PASS. an_uncontained_target reads the +// ABSENT population only, which is correct for what it names -- and it was the ONLY guard here, so a +// requirement whose locator is occupied by an authored source walked straight through and got +// published. The census separates the two populations precisely so both get read; wiring only the +// absent half re-created the conflation one layer up. +fn a_source_occupied_target(store: ObjectStore) -> ObjectId? { + fold(table_source_occupied_identities(store: store), init: none, f: fn(acc, id) { + match acc { + Present { value: _ } => acc + Absent => Present { value: id } + } + }) +} + fn an_uncontained_target(store: ObjectStore) -> ObjectId? { fold(table_unresolved_identities(store: store), init: none, f: fn(acc, id) { match acc { @@ -548,6 +563,10 @@ fn an_uncontained_target(store: ObjectStore) -> ObjectId? { fn encode_repository_checked(repo: RepositoryEnvelope) -> RepositoryEncodeOutcome { let acc = encode_object_table(store: repo.store) + match a_source_occupied_target(store: repo.store) { + Present { value: target } => + RepositoryEncodeRefused { cause: RepositoryEncodeTargetIsAuthoredSource { target: target } } + Absent => match an_uncontained_target(store: repo.store) { Present { value: target } => RepositoryEncodeRefused { cause: RepositoryEncodeUncontainedTarget { target: target } } @@ -580,6 +599,7 @@ fn encode_repository_checked(repo: RepositoryEnvelope) -> RepositoryEncodeOutcom } } } + } } // These answer with the ObjectId the CALLER supplied, not with a wire string derived from it. The diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index 9933f9bc951..6b5119a399b 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -42,7 +42,7 @@ import std.decl_ref { DeclarationRef } import gunbc.scm.object_store { ObjectId, object_id_eq } import gunbc.scm.load_standing { LoadStanding, LoadComplete, LoadPartial, LoadUnsupportedProtocol, - LoadDocumentMalformed, LoadIdentityCollision, + LoadDocumentMalformed, LoadIdentityCollision, LoadRequiresNodeAtAuthoredSource, } // A NAMED PLACE HOLDING EXACTLY ONE GENERATION. The slot is the unit supersession destroys, so it @@ -179,6 +179,8 @@ fn standing_permission_refusal(standing: LoadStanding) -> SupersessionRefusalCau LoadPartial => Present { value: StandingDoesNotPermit { standing: LoadPartial } } LoadUnsupportedProtocol => Present { value: StandingDoesNotPermit { standing: LoadUnsupportedProtocol } } + LoadRequiresNodeAtAuthoredSource => + Present { value: StandingDoesNotPermit { standing: LoadRequiresNodeAtAuthoredSource } } LoadIdentityCollision => Present { value: StandingDoesNotPermit { standing: LoadIdentityCollision } } } diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index f38f3766564..20a22ff1537 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -101,6 +101,7 @@ import gunbc.scm.load_standing { LoadUnsupportedProtocol, LoadDocumentMalformed, LoadIdentityCollision, + LoadRequiresNodeAtAuthoredSource, } import gunbc.scm.merge { MergeOutcome, @@ -433,6 +434,7 @@ test fn scm_image_a_frozen_predecessor_document_is_a_protocol_gap_not_damage() - LoadPartial => false LoadDocumentMalformed => false LoadIdentityCollision => false + LoadRequiresNodeAtAuthoredSource => false } } } diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index b379dfba155..e6f7d09ca93 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -13,7 +13,7 @@ module test.claim.scm_commit_closure_witness // merely non-empty. `a_grafted_root_names_exactly_the_child_it_lacks` is the test that decides it. import std.types { Bool, Int, String, List, list_length } -import v2.std.node { Node, Edge, Symbol, Named, Conj, Atom, TypeNode, node_synthetic } +import v2.std.node { Node, Edge, Symbol, Named, Conj, Atom, Cardinality, Positional, TypeNode, node_synthetic } import gunbc.scm.object_store { ObjectId, ObjectStore, @@ -45,8 +45,16 @@ import extdeps.languages.json.parse { JsonDocumentParsed, JsonDocumentUnreadable, } -import gunbc.scm.json_member { MemberReadDuplicated, MemberReadWrongShape } +import gunbc.scm.json_member { + MemberReadDuplicated, + MemberReadWrongShape, + MemberRead, + MemberArray, + MemberObject, + read_member, +} import gunbc.scm.target_reference { + TargetReference, TargetContainedAt, TargetUncontained, TargetReferenceRefused, @@ -882,3 +890,131 @@ test fn a_genuinely_partial_store_does_not_encode_as_the_complete_one() -> Bool } } } + +// ------------------------------------------------------------------------------------------------ +// THE SPELLING ITSELF, BECAUSE BYTE EQUALITY DOES NOT PIN IT. +// +// one_closure_has_one_image_however_its_store_was_assembled compares two serializations and requires +// them equal. That catches a DISAGREEMENT between the two assembly routes, which is what the graft +// defect produced -- and external review named the hole it leaves: if the encoder spelled EVERY +// target uncontained, both images would still agree and the claim would still pass. Agreement is not +// correctness; it is only consistency. +// +// So this reads the arm out of the document. The closure is complete, so the parent's child target +// must carry "at" -- and decode_target_reference is the module's own reader, not a second parser +// written here to grade the first one. +fn target_arm_of_first_child(objects: List) -> TargetReference? { + fold(objects, init: none, f: fn(found, entry) { + match found { + Present { value: _ } => found + Absent => + match read_member(v: entry, key: "children") { + MemberArray { elements: edges } => + fold(edges, init: none, f: fn(inner, edge) { + match inner { + Present { value: _ } => inner + Absent => + match read_member(v: edge, key: "target") { + MemberObject { value: t } => + Present { value: decode_target_reference(target_value: t) } + _ => none + } + } + }) + _ => none + } + } + }) +} + +fn document_objects(v: JsonValue) -> List { + match read_member(v: v, key: "objects") { + MemberArray { elements: e } => e + _ => [] + } +} + +// A COMPLETE CLOSURE SPELLS ITS CHILD CONTAINED, whichever order its store was assembled in. +test fn a_complete_closure_spells_its_child_contained() -> Bool { + let full = build_full_store() + let completed = build_grafted_then_completed_store() + match encode_complete_closure_document( + closure: CommitClosure { store: completed, root: full.root } + ) { + ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: image } => + match target_arm_of_first_child(objects: document_objects(v: image)) { + Absent => false + Present { value: arm } => + match arm { + TargetContainedAt { reference: _ } => true + TargetUncontained { digest: _ } => false + TargetReferenceRefused { cause: _ } => false + } + } + } +} + +// THE CONTROL. A genuinely partial closure must still spell its missing child UNCONTAINED -- without +// this, the claim above is satisfied by an encoder that never emits the uncontained arm, which would +// delete the partial-closure format outright. +test fn a_partial_closure_spells_its_missing_child_uncontained() -> Bool { + let full = build_full_store() + let grafted = build_grafted_store() + match admitted_for(store: grafted, root: full.root) { + ClosureCompleteSoNothingToAdmit => false + ClosureRequiresNodeAtAuthoredSource { identities: _ } => false + PartialClosureAdmitted { admitted: a } => + match target_arm_of_first_child( + objects: document_objects(v: encode_admitted_partial_closure_document(admitted: a)) + ) { + Absent => false + Present { value: arm } => + match arm { + TargetUncontained { digest: _ } => true + TargetContainedAt { reference: _ } => false + TargetReferenceRefused { cause: _ } => false + } + } + } +} + +// THE POSITIONAL CONTROL FOR THE CANONICALIZATION, and it is the half that says the sort is a RULE +// rather than a habit. +// +// insert_from_structure now canonicalizes its children, and re_writing_the_same_bindings_in_another_ +// order_is_idempotent says two authored orders of the same NAMED bindings are one object. On its own +// that claim is satisfied by sorting every child list unconditionally -- which would be wrong, and +// silently so: under Cardinality and Instantiation the discipline is PositionalEdges, where position +// IS the meaning and reordering names a different program. External review named this gap. +// +// The canonicalizer already answers correctly, because it dispatches on kind_edge_discipline and +// returns positional items untouched. Nothing here executed that, so the rule was true and unwitnessed +// -- and an unwitnessed rule is one a later local sort can quietly replace. +test fn positional_children_are_not_reordered_by_canonicalization() -> Bool { + let fx = permutation_fixture() + fx.ok + && match insert_from_structure( + store: fx.store, + kind: TypeNode { connective: Cardinality }, + children: [ + StoredEdge { label: Positional, target: fx.alpha }, + StoredEdge { label: Positional, target: fx.beta }, + ], + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: once, node: first_ref } => + match insert_from_structure( + store: once, + kind: TypeNode { connective: Cardinality }, + children: [ + StoredEdge { label: Positional, target: fx.beta }, + StoredEdge { label: Positional, target: fx.alpha }, + ], + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => false + Stored { store: _, node: second_ref } => + !object_id_eq(left: first_ref.locator, right: second_ref.locator) + } + } +} diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index fbf2eb05ba4..ca76771b1cc 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -20,7 +20,7 @@ import gunbc.scm.object_store { } import gunbc.scm.load_standing { LoadStanding, LoadComplete, LoadPartial, LoadUnsupportedProtocol, - LoadDocumentMalformed, LoadIdentityCollision, + LoadDocumentMalformed, LoadIdentityCollision, LoadRequiresNodeAtAuthoredSource, standing_loaded, } import std.decl_ref { DeclarationRef } @@ -148,6 +148,7 @@ fn standing_tag(s: LoadStanding) -> Int { LoadUnsupportedProtocol => 2 LoadDocumentMalformed => 3 LoadIdentityCollision => 4 + LoadRequiresNodeAtAuthoredSource => 5 } } diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 3d0a1b9e642..a513491695b 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -42,7 +42,7 @@ import gunbc.scm.repository_envelope { empty_repository, encode_repository, decode_repository, repository_envelope_format_tag, repository_object_identity_rule_tag, RepositoryEncodeOutcome, RepositoryEncoded, RepositoryEncodeRefused, - RepositoryEncodeRefusal, RepositoryEncodeUncontainedTarget, + RepositoryEncodeRefusal, RepositoryEncodeUncontainedTarget, RepositoryEncodeTargetIsAuthoredSource, RepositoryCommitRootNotInStore, RepositoryCheckoutNotInCommits, RepositoryCommitParentNotInCommits, RepositoryCommitReferenceDuplicated, RepositoryCommitReferenceDuplicatedOnWire, @@ -514,6 +514,7 @@ fn scm_env_encode_cause_tag(outcome: RepositoryEncodeOutcome) -> String { RepositoryEncodeRefused { cause: cause } => match cause { RepositoryEncodeUncontainedTarget { target: _ } => "uncontained_target" + RepositoryEncodeTargetIsAuthoredSource { target: _ } => "target_is_authored_source" RepositoryCommitRootNotInStore { root: _ } => "commit_root" RepositoryCheckoutNotInCommits { selected: _ } => "checked_out" RepositoryCommitParentNotInCommits { parent: _ } => "commit_parent" @@ -663,6 +664,7 @@ fn scm_env_named_target_is_uncontained(outcome: RepositoryEncodeOutcome, store: fold(table_unresolved_identities(store: store), init: false, f: fn(acc, id) { acc || object_id_eq(left: id, right: t) }) + RepositoryEncodeTargetIsAuthoredSource { target: _ } => false RepositoryCommitRootNotInStore { root: _ } => false RepositoryCheckoutNotInCommits { selected: _ } => false RepositoryCommitParentNotInCommits { parent: _ } => false @@ -1237,3 +1239,41 @@ test fn env_a_commit_root_that_is_a_node_mints() -> Bool { RepositoryCommitMintRootMissing { root: _ } => false } } + +// A SOURCE-OCCUPIED TARGET REFUSES TO ENCODE, AND IT IS ITS OWN REFUSAL. +// +// The guard beside this one, an_uncontained_target, reads the ABSENT population -- correctly, for what +// it names. It was the ONLY guard here, so a requirement whose locator is occupied by an authored +// source walked straight through and got PUBLISHED. External review found it: the object model split +// the census into two populations precisely so both would be read, and wiring only the absent half +// re-created the conflation one layer up, in the repository encoder. +// +// The two refusals stay separate because their remedies are: one says fetch an object that is not +// here, the other says something is here and it is the wrong kind, which no fetch repairs. +test fn scm_env_a_source_occupied_target_refuses_to_encode_as_its_own_cause() -> Bool { + let occupied = match store_authored_source(store: empty_store(), text: "not a program\n") { + SourceLocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() + SourceStored { store: with_file, source: file } => + match insert_from_structure( + store: with_file, + kind: TypeNode { connective: Conj }, + children: [ + StoredEdge { label: Named { name: ^only_child }, target: SemanticNodeTarget { locator: file.locator } }, + ], + ) { + LocatorCollision { identity: _, existing: _, incoming: _ } => empty_store() + Stored { store: assembled, node: _ } => assembled + } + } + (store_object_count(store: occupied) == 2) + && (scm_env_encode_cause_tag( + outcome: encode_repository_checked( + repo: RepositoryEnvelope { + store: occupied, + commits: [], + commit_allocator: MintedIdAllocator { next_ordinal: 0 }, + checked_out: none, + } + ) + ) == "target_is_authored_source") +} diff --git a/dag/test/claim/scm/scm_supersession_witness_test.dag b/dag/test/claim/scm/scm_supersession_witness_test.dag index 04ae1aaf5a0..c35f8fbbf7f 100644 --- a/dag/test/claim/scm/scm_supersession_witness_test.dag +++ b/dag/test/claim/scm/scm_supersession_witness_test.dag @@ -29,7 +29,7 @@ import gunbc.scm.object_store { } import gunbc.scm.load_standing { LoadStanding, LoadComplete, LoadPartial, LoadUnsupportedProtocol, - LoadDocumentMalformed, LoadIdentityCollision, + LoadDocumentMalformed, LoadIdentityCollision, LoadRequiresNodeAtAuthoredSource, } import gunbc.scm.supersession { HeadSlot, SupersessionEvidence, SupersessionOutcome, @@ -220,6 +220,7 @@ fn standing_name(s: LoadStanding) -> String { LoadUnsupportedProtocol => "protocol" LoadDocumentMalformed => "malformed" LoadIdentityCollision => "collision" + LoadRequiresNodeAtAuthoredSource => "source_occupied" } } From 9bdd976f501a2b14c8d35840b6ec101e677cddd6 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 22:40:06 +0000 Subject: [PATCH 18/25] Seal the raw closure encoder behind a carrier the bad state cannot inhabit The SCM reviewer's exact-head ruling on 837115a listed five required closures. 54cd2e4 had already landed three of them (the repository source-occupied refusal, LoadRequiresNodeAtAuthoredSource, and the contained-spelling witness with the mutation receipt they asked for). These are the remaining two, plus the part of the first that their reading was right about and mine was not. encode_complete_closure_document asked unresolved_identities, which projects the ABSENT population. For a requirement whose locator is occupied by an authored source that population is EMPTY -- so the FRONT DOOR returned bytes for the identical state partial admission refused at the side door. My previous claim that "both entry points are closed" was true of admission and false of this one. THE REPAIR IS A CARRIER, NOT A THIRD CHECK. Writing the judgment at each entry point is what produced this bug twice: `.dag` has no module privacy, so encode_closure_document stayed importable and the wrong-kind state stayed representable through it no matter how many front doors were guarded. It now takes a WellKindedClosure -- sole_constructor, mintable only through admit_well_kinded_closure, which runs the census. There is no argument a caller can build that carries the state, so the raw writer needs no claim of its own. An ABSENT population is fine in that carrier and is the partial-closure case the format exists for: it seals KIND, not completeness. encode_admitted_partial_closure_document re-seals rather than re-judges, because admit_partial_commit_closure already refused source_occupied before minting. Also: unresolved_commit_root_step asked position_of, which is the GENERIC object index, so a commit root pointing at a file found a position and passed. It asks node_position_of now. Repository roots are established against node positions, not object occupancy. The seal is not decorative -- it broke three fixture call sites that had been handing the raw encoder a store and a root, which is exactly the shape a production caller could have used. the_complete_writer_refuses_a_wrong_kind_closure_and_names_it the_complete_writer_still_encodes_a_complete_closure (control) SCM suite 319 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure.dag | 43 ++++++++++++++++ dag/gunbc/scm/commit_closure_json_v2.dag | 49 ++++++++++++++----- dag/gunbc/scm/repository_envelope.dag | 3 +- ...cm_commit_closure_json_v2_witness_test.dag | 27 ++++++++-- .../scm/scm_commit_closure_witness_test.dag | 46 +++++++++++++++++ 5 files changed, 150 insertions(+), 18 deletions(-) diff --git a/dag/gunbc/scm/commit_closure.dag b/dag/gunbc/scm/commit_closure.dag index e2ff5c0f72a..69212d339c3 100644 --- a/dag/gunbc/scm/commit_closure.dag +++ b/dag/gunbc/scm/commit_closure.dag @@ -215,6 +215,49 @@ fn admitted_unresolved(admitted: AdmittedPartialCommitClosure) -> List admitted.unresolved } +// An admitted partial closure is well-kinded BY CONSTRUCTION: admit_partial_commit_closure refuses the +// source_occupied population before it mints one, so this re-seals rather than re-judges. +fn admitted_well_kinded(admitted: AdmittedPartialCommitClosure) -> WellKindedClosure { + WellKindedClosure { closure: admitted.closure } +} + +// A CLOSURE WHOSE EVERY REQUIREMENT IS SATISFIABLE, WHICH IS NOT THE SAME AS COMPLETE. +// +// The encoder must never see a closure requiring a semantic node where an authored source sits: it +// has no honest spelling for that. Emitting the contained arm publishes bytes the decoder refuses; +// emitting the uncontained arm publishes a fetchable absence that no fetch can repair. Both were +// shipped on this branch in turn, which is why the guarantee is now a CARRIER rather than a check -- +// `.dag` has no module privacy, so a judgment written at the two intended entry points leaves the raw +// encoder importable and the bad state perfectly representable through it. +// +// sole_constructor seals it: the only way to obtain one is admit_well_kinded_closure, which runs the +// census. An ABSENT population is fine here and is the partial-closure case the format exists for -- +// this carrier is about KIND, not about completeness. +type WellKindedClosure sole_constructor { + closure: CommitClosure +} + +type WellKindedAdmission + = WellKinded { admitted: WellKindedClosure } + | NotWellKinded { identities: List } + +fn admit_well_kinded_closure(closure: CommitClosure) -> WellKindedAdmission { + let census = closure_census(closure: closure) + if list_length(items: census.source_occupied) > 0 { + NotWellKinded { identities: census.source_occupied } + } else { + WellKinded { admitted: WellKindedClosure { closure: closure } } + } +} + +fn well_kinded_store(admitted: WellKindedClosure) -> ObjectStore { + admitted.closure.store +} + +fn well_kinded_root(admitted: WellKindedClosure) -> SemanticNodeTarget { + admitted.closure.root +} + // A closure is the store plus the root the commit names. Nothing about how it is written down. type CommitClosure { store: ObjectStore diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index bbb29539659..7a46ec1bea4 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -34,6 +34,14 @@ import gunbc.scm.commit_closure { closure_is_complete, closure_census, ClosureCensus, + WellKindedClosure, + WellKindedAdmission, + WellKinded, + NotWellKinded, + admit_well_kinded_closure, + admitted_well_kinded, + well_kinded_store, + well_kinded_root, } import gunbc.scm.object_table_json { ObjectTableDecodeRefusal, @@ -222,6 +230,7 @@ type ClosureDocRefusal // name this cause. DESIGN section 4b's top rung: not validated, not proven, unrepresentable. type ClosureDocEncodeRefusal = ClosureDocIncompleteWithoutAdmission { identity: String } + | ClosureDocRequiresNodeAtAuthoredSource { identities: List } // THE FORMAT TAG MEANS "I UNDERSTAND THIS EXACT SCHEMA", not "I understand some subset of whatever // this writer may have meant". So once the tag matches, every JSON object in the document has a @@ -616,19 +625,31 @@ type ClosureDocEncodeOutcome // admission at all. The wall is on the carrier now, but the module's front door is still ajar. // dissolve-on: module-private functions in .dag, at which point the unchecked encoder stops being // reachable and the gate becomes structural. Until then this comment is the only thing saying so. +// THE COMPLETE WRITER CONSUMES THE WHOLE CENSUS, NOT THE ABSENT HALF. +// +// This asked unresolved_identities, which projects `absent`. For a requirement whose locator is +// occupied by an authored source the absent population is EMPTY, so this saw nothing missing and +// returned bytes -- the wrong-kind state was publishable through the front door while partial +// admission refused it at the side door. Kind is judged first because it is not a degree of +// incompleteness: no supply step repairs it. fn encode_complete_closure_document(closure: CommitClosure) -> ClosureDocEncodeOutcome { - match unresolved_identities(closure: closure) { - missing => - if list_length(items: missing) == 0 { - ClosureDocEncoded { - value: encode_closure_document(store: closure.store, root: closure.root) - } - } else { - ClosureDocEncodeRefused { - cause: ClosureDocIncompleteWithoutAdmission { - identity: an_uncontained_key(missing: missing) + match admit_well_kinded_closure(closure: closure) { + NotWellKinded { identities: identities } => + ClosureDocEncodeRefused { + cause: ClosureDocRequiresNodeAtAuthoredSource { identities: identities } + } + WellKinded { admitted: image } => + match unresolved_identities(closure: closure) { + missing => + if list_length(items: missing) == 0 { + ClosureDocEncoded { value: encode_closure_document(image: image) } + } else { + ClosureDocEncodeRefused { + cause: ClosureDocIncompleteWithoutAdmission { + identity: an_uncontained_key(missing: missing) + } + } } - } } } } @@ -637,7 +658,7 @@ fn encode_complete_closure_document(closure: CommitClosure) -> ClosureDocEncodeO // through a mint that derived the unresolved population from this very closure, so by the time one // exists there is nothing this function could refuse that the mint did not already settle. fn encode_admitted_partial_closure_document(admitted: AdmittedPartialCommitClosure) -> JsonValue { - encode_closure_document(store: admitted.closure.store, root: admitted.closure.root) + encode_closure_document(image: admitted_well_kinded(admitted: admitted)) } // The first identity that would have been written as an uncontained arm. Naming one is what makes @@ -651,7 +672,9 @@ fn an_uncontained_key(missing: List) -> String { }) } -fn encode_closure_document(store: ObjectStore, root: SemanticNodeTarget) -> JsonValue { +fn encode_closure_document(image: WellKindedClosure) -> JsonValue { + let store = well_kinded_store(admitted: image) + let root = well_kinded_root(admitted: image) let acc = encode_object_table(store: store) let root_reference = encoded_root_reference(acc: acc, root: node_target_locator(target: root)) json_object( diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 9fd48aca6b5..792d08de95f 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -102,6 +102,7 @@ import gunbc.scm.commit_closure_json_v2 { encode_object_table, object_id_key, position_of, + node_position_of, encoded_root_reference, decode_object_table, unexpected_member_key, @@ -621,7 +622,7 @@ fn unresolved_commit_root_step(acc: EncodeAcc, found: ObjectId?, commit: Reposit match found { Present { value: _ } => found Absent => - match position_of(positions: acc.positions, identity: commit.root.locator) { + match node_position_of(positions: acc.positions, identity: commit.root.locator) { Present { value: _ } => none Absent => Present { value: commit.root.locator } } diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 20a22ff1537..c51d08eabbc 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -93,7 +93,12 @@ import gunbc.scm.checkout { MissingObject, ObjectIsAuthoredSource, } -import gunbc.scm.commit_closure { CommitClosure } +import gunbc.scm.commit_closure { + CommitClosure, + admit_well_kinded_closure, + WellKinded, + NotWellKinded, +} import gunbc.scm.load_standing { LoadStanding, LoadComplete, @@ -153,6 +158,19 @@ import gunbc.scm.commit_closure_json_v2 { // helper name is a live correctness hazard rather than a style question (#8719). // ------------------------------------------------------------------------------------------------ +// THE RAW ENCODER NOW TAKES A SEALED CARRIER, so a fixture cannot hand it a store and a root. That is +// the point of the seal: `.dag` has no module privacy, and a judgment written only at the intended +// entry points left this importable and the wrong-kind state representable through it. Every fixture +// below therefore states the kind judgment out loud. The refusing arm is unreachable for these +// fixtures -- none of them puts a file under a node's locator -- and it answers an empty document +// rather than a plausible one, so a fixture that ever did reach it fails its own assertion. +fn scm_image_encode_or_empty(store: ObjectStore, root: SemanticNodeTarget) -> JsonValue { + match admit_well_kinded_closure(closure: CommitClosure { store: store, root: root }) { + NotWellKinded { identities: _ } => json_object(members: []) + WellKinded { admitted: image } => encode_closure_document(image: image) + } +} + fn closure_doc_atom(identity: Symbol) -> Node { node_synthetic(kind: TypeNode { connective: Atom { identity: identity } }, children: []) } @@ -206,7 +224,7 @@ fn scm_image_put(store: ObjectStore, n: Node) -> ScmImagePut { fn scm_image_encoded(n: Node) -> JsonValue { let put = scm_image_put(store: empty_store(), n: n) - encode_closure_document(store: put.store, root: put.identity) + scm_image_encode_or_empty(store: put.store, root: put.identity) } // The reconstructed program compared on CONTENT IDENTITY, not on occurrence provenance. checkout @@ -690,7 +708,8 @@ test fn scm_image_a_complete_closure_encodes() -> Bool { let full = scm_image_put(store: empty_store(), n: closure_doc_program()) match encode_complete_closure_document(closure: CommitClosure { store: full.store, root: full.identity }) { ClosureDocEncoded { value: v } => - serialize_json(v: v) == serialize_json(v: encode_closure_document(store: full.store, root: full.identity)) + serialize_json(v: v) + == serialize_json(v: scm_image_encode_or_empty(store: full.store, root: full.identity)) ClosureDocEncodeRefused { cause: _ } => false } } @@ -1504,7 +1523,7 @@ fn scm_image_content_matches(store: ObjectStore, locator: ObjectId, text: String // the decoder's name. The option has no arm to lie in: Absent means the text did not parse, and every // caller answers false to it. fn scm_image_reload_through_text(store: ObjectStore, root: SemanticNodeTarget) -> ClosureDocLoadOutcome? { - match parse_json_document(s: serialize_json(v: encode_closure_document(store: store, root: root))) { + match parse_json_document(s: serialize_json(v: scm_image_encode_or_empty(store: store, root: root))) { JsonDocumentUnreadable { gap: _ } => none JsonDocumentParsed { value: reparsed } => Present { value: decode_closure_document(v: reparsed) } } diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index e6f7d09ca93..d5eb0741c53 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -65,6 +65,7 @@ import gunbc.scm.target_reference { import gunbc.scm.commit_closure_json_v2 { ClosureDocRefusal, ClosureDocIncompleteWithoutAdmission, + ClosureDocRequiresNodeAtAuthoredSource, ClosureDocEncodeRefusal, ClosureDocMemberReadRefused, ClosureDocEncodeOutcome, @@ -423,6 +424,7 @@ test fn a_partial_closure_encodes_only_with_an_admission() -> Bool { fn cause_is_incomplete_without_admission(cause: ClosureDocEncodeRefusal) -> Bool { match cause { ClosureDocIncompleteWithoutAdmission { identity: _ } => true + ClosureDocRequiresNodeAtAuthoredSource { identities: _ } => false } } @@ -677,6 +679,7 @@ test fn a_present_child_round_trips_as_a_complete_closure() -> Bool { closure: CommitClosure { store: full.store, root: full.root } ) { ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: encoded } => match parse_json_document(s: serialize_json(v: encoded)) { JsonDocumentUnreadable { gap: _ } => false @@ -859,11 +862,13 @@ test fn one_closure_has_one_image_however_its_store_was_assembled() -> Bool { closure_is_complete(closure: closure) && match encode_complete_closure_document(closure: closure) { ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: grafted_image } => match encode_complete_closure_document( closure: CommitClosure { store: full.store, root: full.root } ) { ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: natural_image } => serialize_json(v: grafted_image) == serialize_json(v: natural_image) } @@ -884,6 +889,7 @@ test fn a_genuinely_partial_store_does_not_encode_as_the_complete_one() -> Bool closure: CommitClosure { store: full.store, root: full.root } ) { ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: natural_image } => serialize_json(v: encode_admitted_partial_closure_document(admitted: a)) != serialize_json(v: natural_image) @@ -942,6 +948,7 @@ test fn a_complete_closure_spells_its_child_contained() -> Bool { closure: CommitClosure { store: completed, root: full.root } ) { ClosureDocEncodeRefused { cause: _ } => false + ClosureDocEncoded { value: image } => match target_arm_of_first_child(objects: document_objects(v: image)) { Absent => false @@ -1018,3 +1025,42 @@ test fn positional_children_are_not_reordered_by_canonicalization() -> Bool { } } } + +// THE COMPLETE WRITER PRODUCES NO BYTES FOR A WRONG-KIND CLOSURE, and it names the population. +// +// This is the front door the side door already refused. encode_complete_closure_document asked +// unresolved_identities, which projects the ABSENT population -- and for a requirement whose locator +// is occupied by an authored source that population is EMPTY. So the writer saw nothing missing and +// returned bytes, while partial admission refused the identical state. External review found it. +// +// The raw writer beneath it needs no claim of its own any more, and that is the stronger outcome: +// encode_closure_document now takes a WellKindedClosure, which is sole_constructor and mintable only +// through admit_well_kinded_closure. There is no argument a caller can build that carries this state, +// so the refusal is by construction rather than by a judgment the front door happens to run. +test fn the_complete_writer_refuses_a_wrong_kind_closure_and_names_it() -> Bool { + let fx = closure_over_an_occupied_locator() + fx.ok + && match encode_complete_closure_document( + closure: CommitClosure { store: fx.store, root: fx.root } + ) { + ClosureDocEncoded { value: _ } => false + ClosureDocEncodeRefused { cause: c } => + match c { + ClosureDocRequiresNodeAtAuthoredSource { identities: ids } => + (list_length(items: ids) == 1) && names_exactly(missing: ids, expected: fx.child) + ClosureDocIncompleteWithoutAdmission { identity: _ } => false + } + } +} + +// THE CONTROL. Without it the claim above is satisfied by a writer that refuses everything, which +// would delete the format. A genuinely complete closure still encodes. +test fn the_complete_writer_still_encodes_a_complete_closure() -> Bool { + let full = build_full_store() + match encode_complete_closure_document( + closure: CommitClosure { store: full.store, root: full.root } + ) { + ClosureDocEncoded { value: v } => json_is_object(v: v) + ClosureDocEncodeRefused { cause: _ } => false + } +} From 103f2d529075eed47d4f8c07e720e795854faf46 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Tue, 1 Sep 2026 23:30:38 +0000 Subject: [PATCH 19/25] Separate a wrong-kind commit root from an absent one, and execute the new standing Four blockers from the SCM reviewer's exact-head ruling on 9bdd976. 1. COMMIT ROOT. Asking node_position_of was the right first question, but every node-index miss became RepositoryCommitRootNotInStore -- reporting ABSENCE for an object that is present and wrong-kind, the same conflation one layer later. commit_root_standing now answers three ways and the encoder grew RepositoryEncodeCommitRootIsAuthoredSource. HONESTY NOTE ON ITS EVIDENCE, because the arm ships without a discriminating RED and that must not pass silently. I could not author one. RepositoryCommit.root is a SemanticNodeObjectRef, sole_constructor, obtainable only by storing a node; reaching this arm needs a store where that same LOCATOR holds a file, which is a 64-bit digest collision. No fixture can construct it without the forge factory this module refuses to export. Per DESIGN section 4b the arm is therefore mitigatable-with-unauthorable-RED, and its next-rung trigger is a fixture capability that can seed a store at a chosen locator. I am not claiming coverage for it. 2. THE NEW STANDING IS NOW EXECUTED. LoadRequiresNodeAtAuthoredSource had an implementation, exhaustive plumbing, and no evidence any external document reaches it rather than LoadPartial -- a typed carrier and a tag are not a consumer. The document is authored BY HAND because the encoders now refuse to produce it, which is exactly section 4b's case for authoring a RED as source handed to the decoder. scm_image_a_requirement_over_a_carried_file_is_not_partial scm_image_a_requirement_over_nothing_is_partial_and_loaded (control) Mutation, the one the reviewer named: routing source_occupied back through the absent branch takes the first red and leaves the control green. 3. THE MANIFEST TRANSITION RULE was wrong. It said a manifest branch "joins this same tag; it does not mint a third one". The first half is right, the second does not follow: joining one authority is the section 3 point, but adding an arm changes the authority's TOTAL LAW, and this tag names the law. The tag is not pre-bumped for a branch that does not exist; the rule is corrected so the next author does not read it as permission to grow the law under a fixed name. 4. TWO SOURCE COMMENTS ASSERTED THINGS THAT ARE FALSE. object_store said "every node-requiring position takes the node brand; a file in a child edge is then not refused, it is UNWRITABLE." That was true of a design this module no longer has. It is the worst kind of stale comment -- one asserting a SAFETY property, which a reader plans against -- and an external review restated it back as the module's guarantee, which is how a false comment becomes a false shared premise. The honest statement is rung 3, not rung 4: the state is writable and every consumer refuses it by name. Three real defects were found in this seam AFTER the brands landed; had it been unwritable, none could have existed. commit_closure_json_v2 still called the schema addition "a strict extension, not a format version". The module's own policy refutes it -- a schema that grows a member grows a new format tag -- and this codec emits v3 and refuses the frozen predecessor outright. SCM suite 321 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure_json_v2.dag | 12 +- dag/gunbc/scm/object_store.dag | 22 +++- dag/gunbc/scm/repository_envelope.dag | 65 +++++++++-- ...cm_commit_closure_json_v2_witness_test.dag | 107 ++++++++++++++++++ .../scm_repository_envelope_witness_test.dag | 3 + 5 files changed, 192 insertions(+), 17 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 7a46ec1bea4..1863b1f10cc 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -503,9 +503,15 @@ fn encode_record(positions: EncodePositions, record: ObjectRecord) -> JsonValue // THE TWO ENTRY SHAPES ARE DISJOINT MEMBER SETS, AND THAT IS THE DISCRIMINATOR. // -// A node entry keeps `{kind, children}` unchanged, byte for byte, so every document the predecessor -// accepted this codec still accepts and decodes identically -- this is a strict extension, not a -// format version. An authored source entry is `{source}`. Nothing matches both, and an entry +// A node entry keeps `{kind, children}` unchanged, byte for byte. An authored source entry is +// `{source}`. +// +// AN EARLIER REVISION CALLED THAT "a strict extension, not a format version". IT IS NOT, and the +// module's own policy is what refutes it: a schema that grows a member grows a NEW FORMAT TAG. This +// codec emits gunbc-scm-commit-closure-v3 and REFUSES the frozen predecessor tag outright rather than +// accepting it, so the claim that every document the predecessor accepted still decodes here is false +// at the first member the decoder reads. The unchanged node-entry shape is a fact about one entry +// kind, not about compatibility of the format. Nothing matches both, and an entry // matching NEITHER refuses by member set rather than being read as the closest fit: shape-sniffing // is admissible only when the shapes partition, and the refusal is what keeps it a partition rather // than a guess. diff --git a/dag/gunbc/scm/object_store.dag b/dag/gunbc/scm/object_store.dag index e5b9c3d3345..04e18ffbea8 100644 --- a/dag/gunbc/scm/object_store.dag +++ b/dag/gunbc/scm/object_store.dag @@ -66,8 +66,26 @@ type ObjectId = Hash // So a REFERENCE is branded by the kind of object it was minted against, and only this module can // mint one: `sole_constructor` refuses cross-module construction and cast, so no caller can brand a // locator it merely holds. The producers below hand back the brand they derived -- store_node a -// node reference, store_authored_source a source reference -- and every node-requiring position -// takes the node brand. A file in a child edge is then not refused, it is UNWRITABLE. +// node reference, store_authored_source a source reference. +// +// THIS PARAGRAPH ONCE ENDED "and every node-requiring position takes the node brand; a file in a +// child edge is then not refused, it is UNWRITABLE." THAT WAS TRUE OF A DESIGN THIS MODULE NO LONGER +// HAS, and leaving it would have been the worst kind of stale comment -- one asserting a SAFETY +// property, which a reader plans against. An external review restated it back as the module's +// guarantee, which is how a false comment becomes a false shared premise. +// +// What is actually true: StoredEdge.target carries SemanticNodeTarget, a PUBLICLY CONSTRUCTIBLE +// proposition, because a partial closure must reference an object it does not carry and branding that +// would need a mint recording a CLAIM rather than evidence -- the exported forge factory that reopens +// a sole_constructor wall. So a child edge naming a file's locator IS writable. What no consumer will +// do is act on it: resolve_node_target answers three ways, and partial admission, completeness, both +// closure writers, encode_repository_checked and loaded_closure_standing each refuse the wrong-kind +// arm by name. That is section 4b rung 3, structurally guaranteed -- not rung 4, and reporting it as +// rung 4 would be the inflation section 4b(1) forbids. Three real defects were found in this exact +// seam AFTER the brands landed; had the state been unwritable, none of them could have existed. +// +// The one thing that IS unwritable is a CONTAINED WIRE REFERENCE to a file: the encoder's target index +// carries semantic nodes only, so there is no entry to find. // // WHAT THE BRAND DOES NOT CLAIM, stated because a wrapper that overclaims is the hollow alias this // repository rosters. It says: this locator was minted against an object of this kind IN SOME diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 792d08de95f..7cdef954460 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -167,8 +167,15 @@ data repository_envelope_format_tag: String = "gunbc-scm-repository-v2" // subject -- the meaning fork DESIGN section 3 names, in the one place whose whole job is to say // which rule was used. It now names the OBJECT identity rule, and the value names the rule rather // than one branch's hash function, because the rule is a total law over the object coproduct whose -// semantic-node branch delegates to v2.std.node content_hash. A manifest branch joins this same tag -// when it lands; it does not mint a third one. +// semantic-node branch delegates to v2.std.node content_hash. +// +// ON A FUTURE MANIFEST ARM, corrected: this note said such a branch "joins this same tag when it +// lands; it does not mint a third one". The first half is right and the second does not follow. A +// manifest arm joins this identity AUTHORITY rather than standing up a peer authority beside it -- +// that is the section 3 point. But adding an arm and its derivation changes the authority's TOTAL +// LAW over the object coproduct, and this tag names the law, so the version advances with it. The +// tag is not pre-bumped for a branch that does not exist; what is corrected here is the transition +// rule, so the next author does not read this as permission to grow the law under a fixed name. data repository_object_identity_rule_tag: String = "scm-object-identity-v1" // ANCESTRY IS A COMMIT REFERENCE, NOT A NODE REFERENCE, and that is this module's own ruling reused @@ -245,6 +252,7 @@ type RepositoryEnvelope { type RepositoryEncodeRefusal = RepositoryEncodeUncontainedTarget { target: ObjectId } | RepositoryEncodeTargetIsAuthoredSource { target: ObjectId } + | RepositoryEncodeCommitRootIsAuthoredSource { root: ObjectId } | RepositoryCommitRootNotInStore { root: ObjectId } | RepositoryCheckoutNotInCommits { selected: RepositoryCommitRef } | RepositoryCommitReferenceDuplicated { reference: RepositoryCommitRef } @@ -573,9 +581,11 @@ fn encode_repository_checked(repo: RepositoryEnvelope) -> RepositoryEncodeOutcom RepositoryEncodeRefused { cause: RepositoryEncodeUncontainedTarget { target: target } } Absent => match unresolved_commit_root(acc: acc, commits: repo.commits) { - Present { value: root } => + CommitRootAbsent { root: root } => RepositoryEncodeRefused { cause: RepositoryCommitRootNotInStore { root: root } } - Absent => + CommitRootIsAuthoredSource { root: root } => + RepositoryEncodeRefused { cause: RepositoryEncodeCommitRootIsAuthoredSource { root: root } } + CommitRootResolved => match unresolved_checkout_selection(commits: repo.commits, selected: repo.checked_out) { Present { value: reference } => RepositoryEncodeRefused { cause: RepositoryCheckoutNotInCommits { selected: reference } } @@ -606,8 +616,12 @@ fn encode_repository_checked(repo: RepositoryEnvelope) -> RepositoryEncodeOutcom // These answer with the ObjectId the CALLER supplied, not with a wire string derived from it. The // caller holds that identity and can act on it; a hex key it would have to parse back is the same // fact rendered into a shape that has lost its type. -fn unresolved_commit_root(acc: EncodeAcc, commits: List) -> ObjectId? { - fold(commits, init: none, f: fn(found, c) { unresolved_commit_root_step(acc: acc, found: found, commit: c) }) +fn unresolved_commit_root(acc: EncodeAcc, commits: List) -> CommitRootStanding { + fold( + commits, + init: CommitRootResolved, + f: fn(found, c) { unresolved_commit_root_step(acc: acc, found: found, commit: c) } + ) } // ASK THE TABLE, NOT THE ENCODING. This read the ENCODED root and compared it to "" -- the old @@ -618,17 +632,44 @@ fn unresolved_commit_root(acc: EncodeAcc, commits: List) -> Ob // // The repair is to stop routing a containment question through a representation at all. The question // is "does the table hold this object", and position_of answers it directly. -fn unresolved_commit_root_step(acc: EncodeAcc, found: ObjectId?, commit: RepositoryCommit) -> ObjectId? { - match found { - Present { value: _ } => found +// A COMMIT ROOT MISSES THE NODE INDEX FOR TWO DIFFERENT REASONS, AND THEY ARE NOT THE SAME FACT. +// +// Asking node_position_of is the right FIRST question -- a root must resolve to a program, and the +// generic object index answered yes for a file. But treating every node-index miss as +// RepositoryCommitRootNotInStore reports ABSENCE for an object that is present and wrong-kind, which +// is the same conflation this branch closed at the closure grain, arriving one layer later. +// +// The state is inside the declared model rather than hypothetical: a SemanticNodeObjectRef proves node +// kind in SOME store, not in every future one, and a repository may retain a commit while its store is +// replaced. The table's source-occupied scan does not cover it either, because a commit root need not +// be referenced by any ordinary edge. +type CommitRootStanding + = CommitRootResolved + | CommitRootAbsent { root: ObjectId } + | CommitRootIsAuthoredSource { root: ObjectId } + +fn commit_root_standing(acc: EncodeAcc, root: ObjectId) -> CommitRootStanding { + match node_position_of(positions: acc.positions, identity: root) { + Present { value: _ } => CommitRootResolved Absent => - match node_position_of(positions: acc.positions, identity: commit.root.locator) { - Present { value: _ } => none - Absent => Present { value: commit.root.locator } + match position_of(positions: acc.positions, identity: root) { + Present { value: _ } => CommitRootIsAuthoredSource { root: root } + Absent => CommitRootAbsent { root: root } } } } +fn unresolved_commit_root_step( + acc: EncodeAcc, + found: CommitRootStanding, + commit: RepositoryCommit +) -> CommitRootStanding { + match found { + CommitRootResolved => commit_root_standing(acc: acc, root: commit.root.locator) + _ => found + } +} + // A PARENT NAMING A COMMIT THIS REPOSITORY DOES NOT HOLD IS ADJUDICATED BEFORE THE WRITE, for the // same reason the checkout is: encode_repository cannot refuse, so its only honest output is the // empty reference decode later rejects -- which surfaces to whoever LOADS the document rather than to diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index c51d08eabbc..34c9723907e 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -1578,3 +1578,110 @@ test fn scm_image_the_two_authored_arms_stay_distinct_across_the_wire() -> Bool } } } + +// ------------------------------------------------------------------------------------------------ +// THE WRONG-KIND STANDING, EXECUTED AGAINST A REAL DOCUMENT. +// +// LoadRequiresNodeAtAuthoredSource had an implementation, exhaustive plumbing at every consumer, and +// NO executed evidence that an external document actually reaches it rather than LoadPartial. That is +// the shape DESIGN section 5 names: a typed carrier and a tag are not a consumer. +// +// The document is authored by hand because the encoders now REFUSE to produce it -- which is the +// point of the seal, and also why the RED has to be authored as source rather than encoded. Section +// 4b's test applies exactly here: the state is unrepresentable in the accepted corpus and perfectly +// representable as bytes handed to the decoder, which is what a codec's regression probes are for. +// +// object 0 = an authored source +// object 1 = a semantic node whose only child target is UNCONTAINED and names object 0's locator +// root = object 1 +// +// The child is spelled uncontained rather than contained because a contained edge naming a source is +// refused at decode -- a different, already-witnessed arm. This document loads CLEANLY; what it +// asserts is unsatisfiable, and the standing is the only place that can say so. +fn scm_image_source_entry(text: String) -> JsonValue { + json_object(members: [json_kv(key: "source", value: json_string(s: text))]) +} + +fn scm_image_node_over_uncontained(digest: String) -> JsonValue { + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "conj")), + ] + ) + ), + json_kv( + key: "children", + value: json_array( + elements: [ + json_object( + members: [ + json_kv(key: "label", value: json_string(s: "positional")), + json_kv( + key: "target", + value: json_object( + members: [json_kv(key: "uncontained", value: json_string(s: digest))] + ) + ), + ] + ) + ] + ) + ), + ] + ) +} + +fn scm_image_two_object_document(first: JsonValue, second: JsonValue) -> JsonValue { + json_object( + members: [ + json_kv(key: "format", value: json_string(s: closure_document_format_tag)), + json_kv(key: "objects", value: json_array(elements: [first, second])), + json_kv( + key: "root", + value: json_object(members: [json_kv(key: "at", value: json_string(s: "1"))]) + ), + ] + ) +} + +fn scm_image_source_digest(text: String) -> String { + match store_authored_source(store: empty_store(), text: text) { + SourceLocatorCollision { identity: i, existing: _, incoming: _ } => i.digest as String + SourceStored { store: _, source: s } => s.locator.digest as String + } +} + +fn scm_image_standing_of(v: JsonValue) -> LoadStanding { + closure_document_standing(outcome: decode_closure_document(v: v)) +} + +// THE DISCRIMINATING CLAIM. A node requiring a program at a locator this document fills with a FILE +// is not partial, and must not be told to fetch. +test fn scm_image_a_requirement_over_a_carried_file_is_not_partial() -> Bool { + let digest = scm_image_source_digest(text: "a carried file\n") + let doc = scm_image_two_object_document( + first: scm_image_source_entry(text: "a carried file\n"), + second: scm_image_node_over_uncontained(digest: digest), + ) + match scm_image_standing_of(v: doc) { + LoadRequiresNodeAtAuthoredSource => !standing_loaded(standing: LoadRequiresNodeAtAuthoredSource) + _ => false + } +} + +// CONTROL ONE, one fact apart: nothing at that locator. A genuine absence IS partial, and IS loaded. +test fn scm_image_a_requirement_over_nothing_is_partial_and_loaded() -> Bool { + let doc = scm_image_two_object_document( + first: scm_image_empty_node_entry(), + second: scm_image_node_over_uncontained(digest: "0000000000000000"), + ) + match scm_image_standing_of(v: doc) { + LoadPartial => standing_loaded(standing: LoadPartial) + _ => false + } +} diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index a513491695b..4024d9082b0 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -43,6 +43,7 @@ import gunbc.scm.repository_envelope { repository_envelope_format_tag, repository_object_identity_rule_tag, RepositoryEncodeOutcome, RepositoryEncoded, RepositoryEncodeRefused, RepositoryEncodeRefusal, RepositoryEncodeUncontainedTarget, RepositoryEncodeTargetIsAuthoredSource, + RepositoryEncodeCommitRootIsAuthoredSource, RepositoryCommitRootNotInStore, RepositoryCheckoutNotInCommits, RepositoryCommitParentNotInCommits, RepositoryCommitReferenceDuplicated, RepositoryCommitReferenceDuplicatedOnWire, @@ -515,6 +516,7 @@ fn scm_env_encode_cause_tag(outcome: RepositoryEncodeOutcome) -> String { match cause { RepositoryEncodeUncontainedTarget { target: _ } => "uncontained_target" RepositoryEncodeTargetIsAuthoredSource { target: _ } => "target_is_authored_source" + RepositoryEncodeCommitRootIsAuthoredSource { root: _ } => "commit_root_is_authored_source" RepositoryCommitRootNotInStore { root: _ } => "commit_root" RepositoryCheckoutNotInCommits { selected: _ } => "checked_out" RepositoryCommitParentNotInCommits { parent: _ } => "commit_parent" @@ -665,6 +667,7 @@ fn scm_env_named_target_is_uncontained(outcome: RepositoryEncodeOutcome, store: acc || object_id_eq(left: id, right: t) }) RepositoryEncodeTargetIsAuthoredSource { target: _ } => false + RepositoryEncodeCommitRootIsAuthoredSource { root: _ } => false RepositoryCommitRootNotInStore { root: _ } => false RepositoryCheckoutNotInCommits { selected: _ } => false RepositoryCommitParentNotInCommits { parent: _ } => false From bec5a327a6283d89d1338bc4b45bff7e21fb0543 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 00:14:30 +0000 Subject: [PATCH 20/25] Make the commit-root fold exhaustive instead of rostering its wildcard CI red on 103f2d5, reproduced locally rather than assumed flaky: nfr_roster_receipt: unrostered=1 stale=0 live=187 unrostered: ["gunbc.scm.repository_envelope::unresolved_commit_root_step"] The new fold carried `_ => found` over CommitRootStanding, a closed coproduct. The roster exists to make exactly that visible, and the lens caught it on the first commit that introduced one. There are two ways to satisfy it and only one is right. Rostering the wildcard would record a residue that does not need to exist; the arms are enumerable, so the fold is written out. That also restores what the coproduct is for: a fourth standing would now break this match rather than being silently absorbed by the wildcard into "keep whatever we had" -- which, for a fold whose whole job is to report the FIRST bad root, would have quietly kept reporting the wrong one. nfr_roster_receipt 1 passed SCM suite 321 pass, 0 fail Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/repository_envelope.dag | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 7cdef954460..a12ea4ec891 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -666,7 +666,8 @@ fn unresolved_commit_root_step( ) -> CommitRootStanding { match found { CommitRootResolved => commit_root_standing(acc: acc, root: commit.root.locator) - _ => found + CommitRootAbsent { root: _ } => found + CommitRootIsAuthoredSource { root: _ } => found } } From 689a2b5b56cf2cd59e12cd358eb2ee46d076acf5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 00:53:55 +0000 Subject: [PATCH 21/25] Carry the standing to its destination, scope the image claim, sweep the stale text Three of the four items from the SCM reviewer's ruling on 103f2d5. The fourth is terminal exact-head CI, which follows this push. They accepted the unwitnessed commit-root arm's disposition: keep it, export no forge, claim no coverage. 1. THE NEW STANDING NOW REACHES AN ACTUATOR. The hand-authored document proved LoadRequiresNodeAtAuthoredSource is produced; nothing proved it CHANGED anything. A standing no actuator consumes is a value with a name and no consequence, and the exhaustive arm in supersession was plumbing, not execution. The claim now drives the standing THAT DOCUMENT ACTUALLY PRODUCES -- not a literal handed to the actuator -- through observe_generation into supersede_head_slot with every other condition admitting, so the only thing that can refuse is the standing, and requires exactly StandingDoesNotPermit { standing: LoadRequiresNodeAtAuthoredSource }. The third one-fact cell was also missing: a SEMANTIC NODE carried at that locator must resolve the SAME uncontained spelling to LoadComplete. Without it the pair is about presence rather than kind, and satisfiable by a decoder that treats every uncontained target as unresolved -- which would make LoadComplete unreachable through that spelling. 2. THE IMAGE CLAIM IS RENAMED to a_completed_parent_first_graft_has_the_naturally_ built_image. The old name claimed GLOBAL image invariance across every assembly history -- more than the fixture builds and more than the repository authority grants, since insertion history still decides tie order among INDEPENDENT components. A claim whose name promises more than its body executes is the same inflation as a rung reported above its evidence. 3. THE STALE AUTHORITY TEXT IS ONE SWEEP, not another round of single-paragraph repairs, because that is how the previous two got missed: - repository_envelope said emission order is INSERTION order. It is a derived post-order walk; what survives is the narrower true claim that insertion history decides TIE ORDER among independent components. - "THIS FABRICATES NO v2" sat above an encoder emitting v2. The number moved and the prose did not; it is the v3 statement now. - The repository encode header and its witness header both said "the three causes" / "one claim per cause". The coproduct is larger and one cause is deliberately unwitnessed, so both now say which one and why. - commit_closure_json_v2 credited the index split alone with closing the raw route. It never did -- the carrier does. Both facts are needed and the note now says which does what. - "A missing position is UNREACHABLE" was true only while every closure was complete. For an ADMITTED PARTIAL closure a missing node position IS the uncontained arm, and reading it as unreachable would invite an author to delete the format's reason for existing. - supersession's producer census said loaded_closure_standing answers "only LoadComplete or LoadPartial", and that repository_envelope classifies NOTHING into LoadStanding. Both premises are obsolete; a stale count is exactly what corrupts a census. The conclusion is unchanged and broader. SCM suite 323 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/commit_closure_json_v2.dag | 36 +++--- dag/gunbc/scm/repository_envelope.dag | 20 ++-- dag/gunbc/scm/supersession.dag | 17 ++- ...cm_commit_closure_json_v2_witness_test.dag | 110 ++++++++++++++++++ .../scm/scm_commit_closure_witness_test.dag | 11 +- .../scm_repository_envelope_witness_test.dag | 16 ++- 6 files changed, 179 insertions(+), 31 deletions(-) diff --git a/dag/gunbc/scm/commit_closure_json_v2.dag b/dag/gunbc/scm/commit_closure_json_v2.dag index 1863b1f10cc..492a59825f4 100644 --- a/dag/gunbc/scm/commit_closure_json_v2.dag +++ b/dag/gunbc/scm/commit_closure_json_v2.dag @@ -414,12 +414,17 @@ fn encode_label(l: EdgeLabel) -> List { // the CONTAINED spelling -- bytes whose own decoder then refuses them, because decode resolves that // position to an authored source in a node position. The refusal was correct; the emission was not. // -// SPLITTING THE INDEX IS WHY THIS IS NOW UNWRITABLE RATHER THAN CHECKED. `.dag` has no module -// privacy, so encode_closure_document and encode_object_table are importable and will accept any -// store a caller hands them; guarding only the two intended entry points would leave the bad -// publication perfectly representable through the raw one. External review named exactly that call -// -site census. With the target index carrying only nodes, no reachable caller can emit a contained -// reference to a file, because there is no entry to find. +// SPLITTING THE INDEX IS WHAT MAKES THE CONTAINED SPELLING OF A FILE UNWRITABLE, and that is all it +// does -- an earlier version of this note credited the split alone with closing the raw route, which +// it never did. `.dag` has no module privacy, so guarding the two intended entry points left +// encode_closure_document importable and the wrong-kind state representable through it. +// +// The raw route is closed by a CARRIER, not by this split: encode_closure_document now takes a +// WellKindedClosure, which is sole_constructor and mintable only through admit_well_kinded_closure. +// The two facts are separate and both are needed. The index means no caller can emit a CONTAINED +// reference to a file, because there is no entry to find; the carrier means no caller can hand the +// writer a wrong-kind closure at all, so it cannot emit the UNCONTAINED spelling for one either -- +// which was the leak the index split moved the defect into rather than removed. type EncodePositions { by_identity: Map nodes_by_identity: Map @@ -445,13 +450,18 @@ fn node_position_of(positions: EncodePositions, identity: ObjectId) -> String? { map_lookup(m: positions.nodes_by_identity, key: object_id_key(identity: identity)) } -// A missing position is UNREACHABLE rather than defended against, and the reason is worth stating -// because the fallback would be invisible: store_records_in_dependency_order yields children before -// parents, so by the time an edge is encoded its target already holds a position. Answering some -// plausible reference here would emit an image that decodes to a DIFFERENT program, which is exactly -// the fabricated plausible output DESIGN section 5 forbids. The empty string is not a sentinel the -// decoder tolerates -- ObjectTableEdgeTargetUnresolved refuses it -- so an ordering regression surfaces as -// a typed refusal at load rather than as a silently wrong store. +// A MISSING NODE POSITION IS THE UNCONTAINED ARM, NOT AN ERROR -- and this note said the opposite. +// +// It read "a missing position is UNREACHABLE rather than defended against", which was true when a +// closure could only be encoded complete. It is false now and dangerously so: an ADMITTED PARTIAL +// closure encodes targets whose objects it deliberately does not carry, and a missing node position is +// exactly how such a target becomes {"uncontained": digest}. Reading this as unreachable would make a +// future author treat the Absent arm as dead and "simplify" the format's whole reason for existing. +// +// The narrower statement that survives: a missing node position for a target that dependency ordering +// says SHOULD be contained is an ordering failure, and that case is held by execution rather than by +// this paragraph -- a_complete_closure_spells_its_child_contained requires the contained arm for a +// closure whose child is carried, and its partial sibling requires the uncontained arm when it is not. // A TARGET IS ONE OF TWO ARMS, AND WHICH ONE IS A FACT ABOUT THIS DOCUMENT, NOT ABOUT THE WORLD. // // {"at": "3"} this document CARRIES that object, at that position diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index a12ea4ec891..1b991792990 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -118,10 +118,14 @@ import gunbc.scm.load_standing { // THE ENCODING IS NOT CANONICAL WITH RESPECT TO INSERTION HISTORY, and that is declared here rather // than left for someone to discover by diffing two repositories that ought to match. // -// Objects are emitted in store_records_in_dependency_order, which is INSERTION order. Storing -// program A then B, versus B then A, yields the same semantic repository -- the same objects, the -// same derived identities, the same commits -- but a different position assignment, hence different -// bytes. So two repositories that are semantically equal can serialize differently. +// Objects are emitted in store_records_in_dependency_order. That is now a DERIVED post-order walk +// rather than insertion order -- this note said insertion order and was left stale when the walk +// landed. What survives of the original claim is narrower and still true: the walk fixes the order of +// each object relative to the ones it REFERENCES, and nothing more, so insertion history still decides +// TIE ORDER AMONG INDEPENDENT COMPONENTS. Storing program A then B, versus B then A, yields the same +// semantic repository -- same objects, same derived identities, same commits -- but a different +// position assignment, hence different bytes. So two repositories that are semantically equal can +// still serialize differently, for a smaller reason than before. // // What that costs: byte comparison cannot be used to decide repository equality, and a mirror or a // content-addressed repository image is not yet expressible. What it does NOT cost: nothing about @@ -490,9 +494,11 @@ fn encode_commit_records(acc: EncodeAcc, commits: List) -> Lis // the v1 body must stay frozen, and the seam that lets a v2 arrive beside it has to be somewhere no // author is tempted to "just add the field here". // -// THIS FABRICATES NO v2. There is no second arm, no version enum member for a format that does not -// exist, and no migration claim -- building a synthetic second version now would test an invented -// migration rather than the one the product eventually needs. +// THIS FABRICATES NO v3. There is no third arm, no version enum member for a format that does not +// exist, and no migration claim -- building a synthetic next version now would test an invented +// migration rather than the one the product eventually needs. The sentence said "no v2" while the +// live encoder beneath it emitted v2, which is the stale-authority shape this sweep is clearing: the +// number moved and the prose did not. fn encode_repository(repo: RepositoryEnvelope) -> JsonValue { encode_repository_v2(repo: repo) } diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index 6b5119a399b..a938366f10f 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -145,9 +145,13 @@ fn observe_generation( // payload could hide a second distinction -- through `MemberReadRefusal`'s four modes and // `TargetReferenceRefusal`'s four -- and is the ONLY declaration in the tree that can return // `LoadDocumentMalformed`, so it is the entire permitting surface. -// `loaded_closure_standing` answers only `LoadComplete` or `LoadPartial`. The success path is -// therefore OUTSIDE this relation by construction, not by a check: no load that succeeded can -// authorize destroying what it read. +// `loaded_closure_standing` answers `LoadComplete`, `LoadPartial`, or +// `LoadRequiresNodeAtAuthoredSource`. THIS LINE SAID "only LoadComplete or LoadPartial" and was +// left stale when the third arm landed; the census it belongs to is exactly the thing a stale +// count corrupts. The conclusion is UNCHANGED and is now broader: none of the three permits, so +// the success path stays OUTSIDE this relation by construction rather than by a check -- no load +// that succeeded can authorize destroying what it read, and a load whose requirement sits on a +// file is refused for its own named reason rather than by falling through. // `closure_document_standing` is the join of the two and mints no standing of its own. // // CONSUMERS BEFORE THIS MODULE: none in production. The permitting predicate had witnesses and no @@ -156,8 +160,11 @@ fn observe_generation( // WITH the actuator rather than a guard added to one. // // A NEIGHBOUR THAT IS NOT A ROUTE, checked rather than assumed. `gunbc.scm.repository_envelope` -// decodes the envelope that names a head and carries its own `RepositoryDecodeRefusal`, and it -// classifies NOTHING into `LoadStanding`. DESIGN's recurring-failure-mode census records +// decodes the envelope that names a head and carries its own `RepositoryDecodeRefusal`. IT DOES +// CLASSIFY INTO `LoadStanding`, through `repository_envelope_load_standing` -- this line said it +// classified NOTHING and that premise is obsolete. It remains not a ROUTE for a different and +// better reason: every arm it produces is a REFUSAL standing, and no refusal standing permits, so +// the producer count grew while the permitting surface did not. DESIGN's recurring-failure-mode census records // `repository_load_disposition` collapsing thirteen `ImageRefusal` arms as a specimen of the // total-but-blind class; that symbol no longer exists in the tree, and the envelope decoder has // no route to this destination today. The specimen stands as a historical record; it is not a diff --git a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag index 34c9723907e..df22cfa2f70 100644 --- a/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_json_v2_witness_test.dag @@ -93,6 +93,17 @@ import gunbc.scm.checkout { MissingObject, ObjectIsAuthoredSource, } +import std.decl_ref { DeclarationRef, decl_ref } +import gunbc.scm.supersession { + HeadSlot, + SupersessionOutcome, + SupersessionApplied, + SupersessionRefused, + SupersessionRefusalCause, + StandingDoesNotPermit, + observe_generation, + supersede_head_slot, +} import gunbc.scm.commit_closure { CommitClosure, admit_well_kinded_closure, @@ -1685,3 +1696,102 @@ test fn scm_image_a_requirement_over_nothing_is_partial_and_loaded() -> Bool { _ => false } } + +// CONTROL TWO, and it is the cell that keeps the pair from being about presence rather than KIND. A +// SEMANTIC NODE carried at that locator resolves the SAME uncontained spelling to a complete closure. +// Without it, the two cells above are satisfied by a decoder that treats every uncontained target as +// unresolved regardless of what the document carries -- which would make LoadComplete unreachable +// through this spelling and delete the reason dependency order is derived at all. +fn scm_image_leaf_entry(tag: String) -> JsonValue { + json_object( + members: [ + json_kv( + key: "kind", + value: json_object( + members: [ + json_kv(key: "kind", value: json_string(s: "type")), + json_kv(key: "connective", value: json_string(s: "atom")), + json_kv(key: "identity", value: json_string(s: tag)), + ] + ) + ), + json_kv(key: "children", value: json_array(elements: [])), + ] + ) +} + +fn scm_image_leaf_digest(tag: Symbol) -> String { + match store_node( + store: empty_store(), + n: node_synthetic(kind: TypeNode { connective: Atom { identity: tag } }, children: []), + ) { + LocatorCollision { identity: i, existing: _, incoming: _ } => i.digest as String + Stored { store: _, node: n } => n.locator.digest as String + } +} + +test fn scm_image_a_requirement_over_a_carried_node_is_complete_and_loaded() -> Bool { + let doc = scm_image_two_object_document( + first: scm_image_leaf_entry(tag: "carried_leaf"), + second: scm_image_node_over_uncontained(digest: scm_image_leaf_digest(tag: ^carried_leaf)), + ) + match scm_image_standing_of(v: doc) { + LoadComplete => standing_loaded(standing: LoadComplete) + _ => false + } +} + +// THE DOWNSTREAM DESTINATION, because a standing that no actuator consumes is a value with a name and +// no consequence. This drives the standing THAT DOCUMENT ACTUALLY PRODUCES -- not a literal handed to +// the actuator -- into the real supersession path, with every other condition admitting, so the only +// thing that can refuse is the standing itself. +fn scm_image_wrong_kind_document() -> JsonValue { + scm_image_two_object_document( + first: scm_image_source_entry(text: "a carried file\n"), + second: scm_image_node_over_uncontained(digest: scm_image_source_digest(text: "a carried file\n")), + ) +} + +fn scm_image_slot() -> HeadSlot { + HeadSlot { slot: "head", generation: scm_image_generation(tag: ^scm_image_current) } +} + +fn scm_image_generation(tag: Symbol) -> ObjectId { + match store_node( + store: empty_store(), + n: node_synthetic(kind: TypeNode { connective: Atom { identity: tag } }, children: []), + ) { + Stored { store: _, node: n } => n.locator + LocatorCollision { identity: i, existing: _, incoming: _ } => i + } +} + +test fn scm_image_a_wrong_kind_document_cannot_supersede_a_head_slot() -> Bool { + let slot = scm_image_slot() + let standing = scm_image_standing_of(v: scm_image_wrong_kind_document()) + match supersede_head_slot( + slot: slot, + evidence: observe_generation( + slot: slot, + examined_root: slot.generation, + schema_identity: closure_document_format_tag, + standing: standing, + authority: decl_ref( + module_path: "test.claim.scm_commit_closure_json_v2_witness", + decl_name: "scm_image_a_wrong_kind_document_cannot_supersede_a_head_slot", + ), + ), + proposed_generation: scm_image_generation(tag: ^scm_image_proposed), + ) { + SupersessionApplied { slot: _, warrant: _ } => false + SupersessionRefused { cause: c } => + match c { + StandingDoesNotPermit { standing: s } => + match s { + LoadRequiresNodeAtAuthoredSource => true + _ => false + } + _ => false + } + } +} diff --git a/dag/test/claim/scm/scm_commit_closure_witness_test.dag b/dag/test/claim/scm/scm_commit_closure_witness_test.dag index d5eb0741c53..da2be023646 100644 --- a/dag/test/claim/scm/scm_commit_closure_witness_test.dag +++ b/dag/test/claim/scm/scm_commit_closure_witness_test.dag @@ -855,7 +855,14 @@ fn build_grafted_then_completed_store() -> ObjectStore { // the SAME closure -- same objects, same root, same identities -- so they must serialize to the same // document. Under the insertion-order encoder they do not: one spells the child target contained and // the other spells it uncontained, and the byte comparison is what refuses that. -test fn one_closure_has_one_image_however_its_store_was_assembled() -> Bool { +// +// THE NAME IS SCOPED TO THE TWO HISTORIES THE FIXTURE ACTUALLY BUILDS. It was +// `one_closure_has_one_image_however_its_store_was_assembled`, which claims GLOBAL image invariance +// across every assembly history -- more than this fixture establishes and more than the repository +// authority grants, since insertion history still decides tie order among INDEPENDENT components. +// External review named the overreach. A claim whose name promises more than its body executes is the +// same inflation as a rung reported above its evidence. +test fn a_completed_parent_first_graft_has_the_naturally_built_image() -> Bool { let full = build_full_store() let completed = build_grafted_then_completed_store() let closure = CommitClosure { store: completed, root: full.root } @@ -900,7 +907,7 @@ test fn a_genuinely_partial_store_does_not_encode_as_the_complete_one() -> Bool // ------------------------------------------------------------------------------------------------ // THE SPELLING ITSELF, BECAUSE BYTE EQUALITY DOES NOT PIN IT. // -// one_closure_has_one_image_however_its_store_was_assembled compares two serializations and requires +// a_completed_parent_first_graft_has_the_naturally_built_image compares two serializations and requires // them equal. That catches a DISAGREEMENT between the two assembly routes, which is what the graft // defect produced -- and external review named the hole it leaves: if the encoder spelled EVERY // target uncontained, both images would still agree and the claim would still pass. Agreement is not diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index 4024d9082b0..cd3c6d44f21 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -503,10 +503,18 @@ test fn scm_env_a_valid_commit_reference_checks_out() -> Bool { } // ------------------------------------------------------------------------------------------------ -// THE CHECKED ENCODER, with one claim per cause rather than one claim that "it refuses". The three -// causes have different remedies -- an incomplete object table, a commit naming a missing object, a -// checkout naming a non-commit -- so a single boolean would let any two of them regress silently -// while the third kept the claim green. +// THE CHECKED ENCODER, with one claim per cause rather than one claim that "it refuses". The causes +// have different remedies -- an incomplete object table, a table target occupied by a file, a commit +// naming a missing object, a commit root occupied by a file, a checkout naming a non-commit -- so a +// single boolean would let any of them regress silently while another kept the claim green. +// +// THE ROSTER IS NO LONGER ONE CLAIM PER CAUSE, AND THAT IS DECLARED RATHER THAN LEFT TO INFERENCE. +// RepositoryEncodeCommitRootIsAuthoredSource has NO claim, because reaching it needs a store where an +// honestly minted SemanticNodeObjectRef's locator holds a file -- a 64-bit digest collision. No +// fixture can build that without a chosen-locator forge, and exporting one to redden this arm would +// weaken the construction authority the arm exists to defend. It is declared in the module as +// mitigatable-with-unauthorable-RED; this header says so too, so a reader counting claims against +// causes finds the gap named rather than discovering it. // ------------------------------------------------------------------------------------------------ fn scm_env_encode_cause_tag(outcome: RepositoryEncodeOutcome) -> String { From 66f72d5c9c901c070022c4bb71c089771bf06664 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 03:15:01 +0000 Subject: [PATCH 22/25] State the populations that exist, not the ones these notes were written against The reviewer's hold on 689a2b5 was text-only but authority-bearing: three source notes described populations their own subjects had outgrown, and one asserted a rule in the present tense directly above its retraction. The supersession census is recomputed from the live tree rather than patched. It said "PRODUCERS, TOTAL: two" beside "the producer count grew"; both cannot hold at the declared denominator. There are five, across three modules. Recounting surfaced a second, load-bearing falsehood the review did not name. The census also claimed closure_document_load_standing was the ONLY declaration able to return LoadDocumentMalformed, and concluded that the producer count grew while the permitting surface did not. LoadDocumentMalformed is the sole permitting standing, and repository_envelope_load_standing returns it for its member-read and target-reference modes while object_table_load_standing returns it for six of its seven causes. The permitting surface grew with the producer count. Recording the growth of one while denying the other is exactly the stale count this census exists to catch, so the conclusion is replaced rather than the number. What survives is stated narrowly: the success path stays outside the relation by construction, because none of loaded_closure_standing's three arms permits. RepositoryEncodeRefusal's note described "the single producible cause" and "the two sibling arms" above an eight-arm coproduct, and the checked encoder's note named three ways a repository can be unrepresentable above the same eight. Both now enumerate what exists, grouped by remedy, keeping the domain rule that was each paragraph's actual content. The witness header opened by asserting one claim per cause and retracted it two lines later. The heading now carries the exception it always had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/repository_envelope.dag | 24 +++++--- dag/gunbc/scm/supersession.dag | 57 +++++++++++-------- .../scm_repository_envelope_witness_test.dag | 14 +++-- 3 files changed, 60 insertions(+), 35 deletions(-) diff --git a/dag/gunbc/scm/repository_envelope.dag b/dag/gunbc/scm/repository_envelope.dag index 1b991792990..39a67f20f31 100644 --- a/dag/gunbc/scm/repository_envelope.dag +++ b/dag/gunbc/scm/repository_envelope.dag @@ -250,9 +250,14 @@ type RepositoryEnvelope { // them were real, and a consumer matching it had to handle format-tag and unknown-connective causes // that no encode path can raise. The type was answering for a domain it does not own. // -// The single producible cause is an uncontained target found in the store being written, so that is -// the arm. It carries ObjectId rather than a rendered key, which also closes a string round-trip the -// two sibling arms below never had: an identity left as text is one nobody can resolve back. +// The surviving rule is the domain one: every arm below is a cause ENCODING CAN ACTUALLY RAISE. The +// population is eight, and this line previously said "the single producible cause ... the two sibling +// arms below" -- a description of an earlier, smaller coproduct left standing over a grown one. Three +// arms concern object identity (an uncontained target, a target that resolves to authored source, a +// commit root that does; each carries ObjectId rather than a rendered key, closing a string round-trip +// -- an identity left as text is one nobody can resolve back); one concerns a commit root absent from +// the store; and four concern the commit table (checkout selection, duplication, allocator range, +// parent order), carrying RepositoryCommitRef for the same reason. type RepositoryEncodeRefusal = RepositoryEncodeUncontainedTarget { target: ObjectId } | RepositoryEncodeTargetIsAuthoredSource { target: ObjectId } @@ -534,10 +539,15 @@ type RepositoryEncodeOutcome // reported success, and the corruption surfaces on the next load, to whoever loads it rather than to // whoever caused it. So the adjudication is lifted ahead of the write. // -// The three ways a repository can be unrepresentable are exactly the three arms below, and they are -// separate causes rather than one because their remedies differ: an unresolvable EDGE means the -// object table is an incomplete closure, an unresolvable COMMIT ROOT means a commit names an object -// the store does not hold, and an unresolvable CHECKOUT means the selection names a non-commit. +// The ways a repository can be unrepresentable are the eight arms of RepositoryEncodeRefusal, and +// they are separate causes rather than one because their remedies differ. This line said "the three +// ways ... exactly the three arms below" and was left stale as the coproduct grew; the population it +// describes must be the population that exists. Grouped by remedy: an unresolvable EDGE means the +// object table is an incomplete closure; a target or commit root that resolves to AUTHORED SOURCE +// means something asked a file to stand where a semantic node belongs; an ABSENT commit root means a +// commit names an object the store does not hold; an unresolvable CHECKOUT means the selection names +// a non-commit; and duplication, allocator range and parent order are three ways the commit table +// itself is inconsistent. // THE REPOSITORY STILL REFUSES A PARTIAL OBJECT TABLE, and that is a DELIBERATE non-change in a diff // that made partial closures representable everywhere else. A commit closure may now legitimately be // shipped incomplete, under a declared admission, because a delta is a real thing to send. A whole diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index a938366f10f..99eb580f091 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -140,36 +140,47 @@ fn observe_generation( // destination's whole producer side, not any one defect site. Measured on the live tree rather than // recalled, by enumerating every declaration that returns a `LoadStanding`. // -// PRODUCERS, TOTAL: two, both in `gunbc.scm.commit_closure_json_v2`. -// `closure_document_load_standing` classifies one format's refusals. It descends everywhere a -// payload could hide a second distinction -- through `MemberReadRefusal`'s four modes and -// `TargetReferenceRefusal`'s four -- and is the ONLY declaration in the tree that can return -// `LoadDocumentMalformed`, so it is the entire permitting surface. -// `loaded_closure_standing` answers `LoadComplete`, `LoadPartial`, or -// `LoadRequiresNodeAtAuthoredSource`. THIS LINE SAID "only LoadComplete or LoadPartial" and was -// left stale when the third arm landed; the census it belongs to is exactly the thing a stale -// count corrupts. The conclusion is UNCHANGED and is now broader: none of the three permits, so -// the success path stays OUTSIDE this relation by construction rather than by a check -- no load -// that succeeded can authorize destroying what it read, and a load whose requirement sits on a -// file is refused for its own named reason rather than by falling through. -// `closure_document_standing` is the join of the two and mints no standing of its own. +// PRODUCERS, TOTAL: five, across three modules, enumerated from the live tree. This line has been +// wrong twice -- it said "two, both in `gunbc.scm.commit_closure_json_v2`" while a third module +// already classified -- so it now states the denominator it is measured at: declarations whose +// return type is `LoadStanding`. +// In `gunbc.scm.commit_closure_json_v2`: `closure_document_load_standing` classifies one format's +// refusals, descending through `MemberReadRefusal`'s four modes and `TargetReferenceRefusal`'s +// four; `loaded_closure_standing` answers `LoadComplete`, `LoadPartial`, or +// `LoadRequiresNodeAtAuthoredSource`; `closure_document_standing` is the join of the two and +// mints no standing of its own. +// In `gunbc.scm.repository_envelope`: `repository_envelope_load_standing`. +// In `gunbc.scm.object_table_json`: `object_table_load_standing`. +// +// THE PERMITTING SURFACE IS THREE OF THE FIVE, and this correction is the load-bearing one. The +// sole permitting standing is `LoadDocumentMalformed` -- `standing_permission_refusal` returns +// `none` for it and names a refusal for every other arm. This audit previously claimed +// `closure_document_load_standing` was the ONLY declaration that could return it, and concluded +// that the producer count had grown while the permitting surface had not. THAT CONCLUSION IS +// FALSE: `repository_envelope_load_standing` returns `LoadDocumentMalformed` for its member-read +// and target-reference modes, and `object_table_load_standing` returns it for six of its seven +// causes. The permitting surface grew with the producer count, and recording the growth of one +// while denying the other is precisely the stale-count corruption this census exists to catch. +// +// WHAT SURVIVES THE CORRECTION, and it is not the old conclusion. The success path stays outside +// this relation by construction: `loaded_closure_standing`'s three arms are `LoadComplete`, +// `LoadPartial` and `LoadRequiresNodeAtAuthoredSource`, none of which permits, so no load that +// SUCCEEDED can authorize destroying what it read, and a load whose requirement sits on a file is +// refused for its own named reason rather than by falling through. What does NOT survive is any +// claim that one module owns the permitting surface. Three do, each deciding per cause, and the +// collision arms are the load-bearing ones: `ObjectTableObjectCollision` reaches +// `LoadIdentityCollision` rather than `LoadDocumentMalformed` exactly because the permitting +// standing must not absorb a cause that means "two objects claimed one identity". // // CONSUMERS BEFORE THIS MODULE: none in production. The permitting predicate had witnesses and no // caller, so the hazard was not live -- it was pre-positioned, waiting for the first actuator to // be written against the shape that was there. That is why the repair is a shape change landing // WITH the actuator rather than a guard added to one. // -// A NEIGHBOUR THAT IS NOT A ROUTE, checked rather than assumed. `gunbc.scm.repository_envelope` -// decodes the envelope that names a head and carries its own `RepositoryDecodeRefusal`. IT DOES -// CLASSIFY INTO `LoadStanding`, through `repository_envelope_load_standing` -- this line said it -// classified NOTHING and that premise is obsolete. It remains not a ROUTE for a different and -// better reason: every arm it produces is a REFUSAL standing, and no refusal standing permits, so -// the producer count grew while the permitting surface did not. DESIGN's recurring-failure-mode census records +// A HISTORICAL SPECIMEN, NOT A LIVE PRODUCER. DESIGN's recurring-failure-mode census records // `repository_load_disposition` collapsing thirteen `ImageRefusal` arms as a specimen of the -// total-but-blind class; that symbol no longer exists in the tree, and the envelope decoder has -// no route to this destination today. The specimen stands as a historical record; it is not a -// second live producer, and reporting it as one would have inflated the population from one -// measurement and one recollection. +// total-but-blind class. That symbol no longer exists in the tree. The specimen stands as a +// historical record and is not counted in the five above. // // WHAT THE AUDIT DOES NOT ESTABLISH. One producer today is not a bound on producers tomorrow, and // that is precisely why the destination now demands evidence instead of trusting a cause: a new diff --git a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag index cd3c6d44f21..c3b00a036fa 100644 --- a/dag/test/claim/scm/scm_repository_envelope_witness_test.dag +++ b/dag/test/claim/scm/scm_repository_envelope_witness_test.dag @@ -503,12 +503,16 @@ test fn scm_env_a_valid_commit_reference_checks_out() -> Bool { } // ------------------------------------------------------------------------------------------------ -// THE CHECKED ENCODER, with one claim per cause rather than one claim that "it refuses". The causes -// have different remedies -- an incomplete object table, a table target occupied by a file, a commit -// naming a missing object, a commit root occupied by a file, a checkout naming a non-commit -- so a -// single boolean would let any of them regress silently while another kept the claim green. +// THE CHECKED ENCODER, one claim per cause for every cause but one, WHICH IS NAMED BELOW. The header +// used to open by asserting one-claim-per-cause outright and then retract it two lines later; the +// heading now carries the exception rather than standing false above its own correction. // -// THE ROSTER IS NO LONGER ONE CLAIM PER CAUSE, AND THAT IS DECLARED RATHER THAN LEFT TO INFERENCE. +// The roster is per cause rather than one claim that "it refuses" because the causes have different +// remedies -- an incomplete object table, a table target occupied by a file, a commit naming a +// missing object, a commit root occupied by a file, a checkout naming a non-commit -- so a single +// boolean would let any of them regress silently while another kept the claim green. +// +// THE ONE CAUSE WITHOUT A CLAIM, DECLARED RATHER THAN LEFT TO INFERENCE. // RepositoryEncodeCommitRootIsAuthoredSource has NO claim, because reaching it needs a store where an // honestly minted SemanticNodeObjectRef's locator holds a file -- a 64-bit digest collision. No // fixture can build that without a chosen-locator forge, and exporting one to redden this arm would From e141a7670fbe31ce3723b68283f72dd1d99e9562 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 03:23:00 +0000 Subject: [PATCH 23/25] Exclude witness helpers from the census denominator, by name The recount stated its denominator as "declarations whose return type is LoadStanding". That is too wide: witness modules declare their own, and test.claim.scm_commit_closure_json_v2_witness scm_image_standing_of is one. A fixture helper is not a producer this destination can be reached from, so the denominator is now production classifiers under gunbc.scm.*, and the witness helper is excluded by name rather than by being overlooked. The withdrawn claim -- that the producer count grew while the permitting surface did not -- is removed rather than qualified. The account states positively what holds: LoadDocumentMalformed is the one permitting standing, three of the five producers can answer with it, and no declaration or module owns it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/supersession.dag | 46 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index 99eb580f091..baa9e82d3f0 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -138,12 +138,16 @@ fn observe_generation( // THE AUDIT OF THE AUTHORIZATION RELATION, at the denominator the hazard actually has: this // destination's whole producer side, not any one defect site. Measured on the live tree rather than -// recalled, by enumerating every declaration that returns a `LoadStanding`. +// recalled. // -// PRODUCERS, TOTAL: five, across three modules, enumerated from the live tree. This line has been -// wrong twice -- it said "two, both in `gunbc.scm.commit_closure_json_v2`" while a third module -// already classified -- so it now states the denominator it is measured at: declarations whose -// return type is `LoadStanding`. +// THE DENOMINATOR IS PRODUCTION CLASSIFIERS, and it is stated because the obvious phrasing is +// wrong. "Every declaration that returns a `LoadStanding`" is too wide: witness modules declare +// their own, and `test.claim.scm_commit_closure_json_v2_witness scm_image_standing_of` is one. +// A fixture helper is not a producer this destination can be reached from, so the population +// below is declarations under `gunbc.scm.*` whose return type is `LoadStanding`. Witness helpers +// are excluded BY NAME rather than by being overlooked. +// +// PRODUCERS, TOTAL: five, across three modules. // In `gunbc.scm.commit_closure_json_v2`: `closure_document_load_standing` classifies one format's // refusals, descending through `MemberReadRefusal`'s four modes and `TargetReferenceRefusal`'s // four; `loaded_closure_standing` answers `LoadComplete`, `LoadPartial`, or @@ -152,25 +156,21 @@ fn observe_generation( // In `gunbc.scm.repository_envelope`: `repository_envelope_load_standing`. // In `gunbc.scm.object_table_json`: `object_table_load_standing`. // -// THE PERMITTING SURFACE IS THREE OF THE FIVE, and this correction is the load-bearing one. The -// sole permitting standing is `LoadDocumentMalformed` -- `standing_permission_refusal` returns -// `none` for it and names a refusal for every other arm. This audit previously claimed -// `closure_document_load_standing` was the ONLY declaration that could return it, and concluded -// that the producer count had grown while the permitting surface had not. THAT CONCLUSION IS -// FALSE: `repository_envelope_load_standing` returns `LoadDocumentMalformed` for its member-read -// and target-reference modes, and `object_table_load_standing` returns it for six of its seven -// causes. The permitting surface grew with the producer count, and recording the growth of one -// while denying the other is precisely the stale-count corruption this census exists to catch. +// THE PERMITTING STANDING IS ONE, AND IT IS OWNED BY NO ONE MODULE. `LoadDocumentMalformed` alone +// maps to `none` in `standing_permission_refusal`; every other arm names a refusal. Three of the +// five producers can answer with it: `closure_document_load_standing`, +// `repository_envelope_load_standing` for its member-read and target-reference modes, and +// `object_table_load_standing` for six of its seven causes. No single declaration or module is +// the permitting surface. // -// WHAT SURVIVES THE CORRECTION, and it is not the old conclusion. The success path stays outside -// this relation by construction: `loaded_closure_standing`'s three arms are `LoadComplete`, -// `LoadPartial` and `LoadRequiresNodeAtAuthoredSource`, none of which permits, so no load that -// SUCCEEDED can authorize destroying what it read, and a load whose requirement sits on a file is -// refused for its own named reason rather than by falling through. What does NOT survive is any -// claim that one module owns the permitting surface. Three do, each deciding per cause, and the -// collision arms are the load-bearing ones: `ObjectTableObjectCollision` reaches -// `LoadIdentityCollision` rather than `LoadDocumentMalformed` exactly because the permitting -// standing must not absorb a cause that means "two objects claimed one identity". +// WHAT THAT LEAVES STANDING. The success path is outside the permitting relation by construction: +// `loaded_closure_standing`'s three arms are `LoadComplete`, `LoadPartial` and +// `LoadRequiresNodeAtAuthoredSource`, none of which permits, so no load that SUCCEEDED can +// authorize destroying what it read, and a load whose requirement sits on a file is refused for +// its own named reason rather than by falling through. The collision arm is the load-bearing one: +// `ObjectTableObjectCollision` reaches `LoadIdentityCollision` and not `LoadDocumentMalformed`, +// because the permitting standing must not absorb a cause meaning "two objects claimed one +// identity". // // CONSUMERS BEFORE THIS MODULE: none in production. The permitting predicate had witnesses and no // caller, so the hazard was not live -- it was pre-positioned, waiting for the first actuator to From 5bdb53855d69ac3c285cbcc3090b3fe39e4c5a04 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 04:32:32 +0000 Subject: [PATCH 24/25] Regenerate the Rust source-type bindings from their authority main carries an updated `proof` row in gunbc.rust_source_type_bindings whose committed projection was never regenerated, so origin/main is itself in a drifted state and every branch that merges it inherits a failing regen phase. Measured here rather than assumed: both the .dag authority and the .rs projection on this branch are byte-identical to origin/main, and regen still reports first_generation_equal=false for this one file. The fix is the projection, not the authority: the generated file now carries what the row says. The build lane is green at this head -- regen first_generation_equal=true, generated-artifact 35/35 matched, 0 drifted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- src/v1/stage0/src/gunbc_rust_source_type_bindings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v1/stage0/src/gunbc_rust_source_type_bindings.rs b/src/v1/stage0/src/gunbc_rust_source_type_bindings.rs index 0b07b3f3951..29b2f2b2d38 100644 --- a/src/v1/stage0/src/gunbc_rust_source_type_bindings.rs +++ b/src/v1/stage0/src/gunbc_rust_source_type_bindings.rs @@ -62,7 +62,7 @@ pub fn checkpoint_row_migration_rows() -> Rc>> { }), Rc::new(CheckpointRowMigration { dag_name: "String".to_string(), disposition: Rc::new(CheckpointRowDisposition::ProvenUniqueKernelBinding { - proof: "The kernel string_type is minted by v1.compiler.types for every string literal and carries no declaration; both .dag declarations of the spelling (v2.std.text, std.string_type) are gated structural by v1.compiler.coercion structural_declaration_modules_for and never reach this row. THE VERDICT STANDS AND ITS ORIGINAL ARGUMENT WAS EXACTLY INVERTED, corrected here rather than softened because the inverted form would justify the wrong things if reused. It read: the bare spelling cannot be captured because the only import that could shadow it binds the SAME token, so in a module importing v2.std.text String every `String` reference IS the text carrier and the kernel spelling is unreachable there. The opposite is true. KERNEL NAMES ARE NEVER OVERRIDDEN BY IMPORTS (v1.compiler.infer direct_import_export_precedence_note, overlay_skips_kernel_name), so in such a module the import does not shadow at all and a bare `String` is ALWAYS the kernel -- the spelling the old argument called unreachable is the only one you reach. Three routes exist and all three are sound for this row: a bare reference in an importing module resolves to the kernel and IS this row's binding; a bare reference INSIDE v2.std.text resolves LOCALLY (locals beat kernel) and is gated structural, never reaching the row; and a QUALIFIED v2.std.text.String resolves through the alias to the dotted name std.algebra.FreeMonoid, which matches no bare row. Symbol was captured precisely because its token differed from its spelling, and that clause survives unchanged. ESTABLISHED BY EXECUTION, not by reading precedence: v1.tests.claim.carrier_realization_census run over a fixture module importing both v2.std.text String and v2.std.node Node reports the bare String reference carrying the kernel identity and realizing as the host spelling, against a positive control in the same fixture where a v2.std.node.Node reference -- bare and qualified alike -- carries its real declaring module. The instrument is named rather than its rows transcribed (DESIGN section 6); re-derive by running that census over any closure containing such a module.".to_string(), + proof: "The kernel string_type is minted by v1.compiler.types for every string literal and carries no declaration; both .dag declarations of the spelling (v2.std.text, std.string_type) are gated structural by v1.compiler.coercion structural_declaration_modules_for and never reach this row. THE VERDICT STANDS AND ITS ORIGINAL ARGUMENT WAS EXACTLY INVERTED, corrected here rather than softened because the inverted form would justify the wrong things if reused. It read: the bare spelling cannot be captured because the only import that could shadow it binds the SAME token, so in a module importing v2.std.text String every `String` reference IS the text carrier and the kernel spelling is unreachable there. The opposite is true. KERNEL NAMES ARE NEVER OVERRIDDEN BY IMPORTS (v1.compiler.infer direct_import_export_precedence_note, overlay_skips_kernel_name), so in such a module the import does not shadow at all and a bare `String` is ALWAYS the kernel -- the spelling the old argument called unreachable is the only one you reach. Three routes exist and all three are sound for this row: a bare reference in an importing module resolves to the kernel and IS this row's binding; a bare reference INSIDE v2.std.text resolves LOCALLY (locals beat kernel) and is gated structural, never reaching the row; and a QUALIFIED v2.std.text.String resolves through the alias to the dotted name std.algebra.FreeMonoid, which matches no bare row. Symbol was captured precisely because its token differed from its spelling, and that clause survives unchanged. ESTABLISHED BY EXECUTION, not by reading precedence -- AND THE SUBJECT NAMED HERE IS CORRECTED: this sentence previously said the census was run over A FIXTURE MODULE importing both v2.std.text String and v2.std.node Node. IT WAS NOT. The receipts carry no fixture rows at all; the subjects were LIVE CORPUS MODULES, and naming a subject that was not run is the same citation defect this row exists to correct. What v1.tests.claim.carrier_realization_census actually reports: in std.algebra, a module that imports the spelling, a bare String carries the kernel identity at declaration_field AND at fn_signature_param; inside v2.std.text the same spelling carries its own declaring module and diverges with exact identity, which is route 2 executed rather than argued; and v2.std.node Node carries its real declaring module at both position kinds as the positive control. RE-DERIVATION IS POSITION-KIND SENSITIVE AND THE OBVIOUS INSTRUCTION IS A TRAP. Running that census over a closure re-derives routes 1 and 2 soundly. It does NOT re-derive route 3: at fn_signature_param the census resolves a QUALIFIED v2.std.text.String through the alias onto the alias declaration's own name, which is the spelling String, and attributes it to -- confidently naming the very carrier the qualification was written to move away from. That is measured by vivid-deer-102 and filed as recurring failure mode alias_resolution_collides_with_kernel_spelling (gunbc#9939); the same instrument is correct for qualified aliases at declaration_field. ROUTE 3 THEREFORE RESTS ON STRUCTURAL RESOLUTION PLUS A RUSTC MEASUREMENT -- the emitted crate carries an E0308 on the bare arm and not on the qualified one -- and explicitly NOT on this census, which cannot answer at that position. The instrument is named rather than its rows transcribed (DESIGN section 6).".to_string(), }), }), Rc::new(CheckpointRowMigration { dag_name: "Int".to_string(), From f25132c918e18f02a2b0494ebcfbeacfd044ba8e Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 2 Sep 2026 05:10:12 +0000 Subject: [PATCH 25/25] Two claims quantified over a population they did not check The census repair left its neighbours stating the population as it stood when each sentence was written. Two of those neighbours are claims, and that is the part that matters: their names quantify universally while their bodies enumerated a subset, so the standing this branch introduces could have collapsed into the permitting arm with both green. every_non_permitting_standing_refuses_and_says_which checked four of the five non-permitting standings. malformed_is_the_only_standing_that_permits_replacement checked two of the three it owns (LoadComplete and LoadPartial belong to a_partial_load_is_never_supersedable, so the five are covered between the pair). Both now check the whole population. Executed rather than argued: routing LoadRequiresNodeAtAuthoredSource to the permitting arm takes BOTH claims red, and both are green with the arm restored. Before this change that mutation was invisible to both. The prose repairs are the same defect without the teeth. load_standing said "four refusals ... for four different reasons" and listed four of five, so the new standing had no stated reason; it has one now, and it is its own -- the document decoded and what it asserts is unsatisfiable, so no fetch repairs it and superseding would destroy a generation over a defect not in its bytes. object_table_json called collision "the five"; it is the seventh cause against the other six. The census said "one producer today" after counting five, and "a sixth standing" above a six-arm coproduct. The census partition is restated at the grain the review asked for: three declarations DECIDE LoadDocumentMalformed from a refusal cause, one RELAYS it without deciding, and one cannot return it -- a fourth return route is not a fourth decision authority. The surviving consequence is stated at arm grain (the ClosureDocLoaded branch) rather than as "no load that succeeded", because this module deliberately separates a document that decoded from one whose standing answers standing_loaded true. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ4iThiZ1B9LPB9ePr8qa9 --- dag/gunbc/scm/load_standing.dag | 12 +++++- dag/gunbc/scm/object_table_json.dag | 8 ++-- dag/gunbc/scm/supersession.dag | 39 ++++++++++++------- .../scm/scm_load_standing_witness_test.dag | 13 +++++-- .../scm/scm_supersession_witness_test.dag | 16 ++++++-- 5 files changed, 60 insertions(+), 28 deletions(-) diff --git a/dag/gunbc/scm/load_standing.dag b/dag/gunbc/scm/load_standing.dag index 4e90d97c6fc..1ebd1f223d1 100644 --- a/dag/gunbc/scm/load_standing.dag +++ b/dag/gunbc/scm/load_standing.dag @@ -62,8 +62,10 @@ type LoadStanding // it was equally authoritative and none of them could be joined to the swap it authorized. // // The relation itself survives unchanged in content: `LoadDocumentMalformed` is still the sole -// standing that permits superseding a generation, and the four refusals are still refused for four -// different reasons rather than by one rule -- +// standing that permits superseding a generation, and the FIVE refusals are still refused for five +// different reasons rather than by one rule. This said "four" and listed four while the coproduct +// carried a fifth: `LoadRequiresNodeAtAuthoredSource` landed without being added here, so the +// rationale list was a census of the population as it stood when the sentence was written -- // // LoadComplete nothing is wrong; there is nothing to replace. // LoadPartial the document is CORRECT and deliberately incomplete. Superseding a valid @@ -76,6 +78,12 @@ type LoadStanding // is prevented by construction). A collision means the structural locator // cannot represent both, not that either is damaged -- so superseding // discards a valid generation over an honest hash coincidence. +// LoadRequiresNodeAtAuthoredSource +// the document read fine and what it ASSERTS is unsatisfiable: a position +// that must hold a semantic node names authored source. No fetch repairs +// it, and no rewrite of the bytes is owed -- the writer's generation is +// not damaged, it is describing a graph that cannot exist, so superseding +// would destroy a generation over a defect that is not in its bytes. // LoadDocumentMalformed the tag matched, so this build claims to understand the EXACT schema, // and the document does not conform. This is the only standing where the // bytes are known-bad to a reader that should have understood them. diff --git a/dag/gunbc/scm/object_table_json.dag b/dag/gunbc/scm/object_table_json.dag index 7f95384a2f2..204d425a285 100644 --- a/dag/gunbc/scm/object_table_json.dag +++ b/dag/gunbc/scm/object_table_json.dag @@ -2,15 +2,15 @@ module gunbc.scm.object_table_json // DECODING THE OBJECT TABLE IS ONE OPERATION WITH ONE FAILURE POPULATION, and this module owns both. // It was previously homed inside gunbc.scm.commit_closure_json_v2 with its failures typed as THAT -// module's ClosureDocRefusal, so six object-population causes sat in a coproduct that also answers +// module's ClosureDocRefusal, so seven object-population causes sat in a coproduct that also answers // for envelope format and closure-root facts. A consumer matching that type had to handle causes // from three different owners, and no reader could tell from the type which owner produced which. // // THE MEASURED COST WAS NOT STYLISTIC, and it is the reason this cut is a correctness fix rather // than tidiness. closure_document_load_standing maps ObjectTableObjectCollision to -// LoadIdentityCollision and the five tag/reference causes to LoadDocumentMalformed. Per +// LoadIdentityCollision and the OTHER SIX tag/reference causes to LoadDocumentMalformed. Per // gunbc.scm.load_standing, LoadDocumentMalformed is THE ONLY standing that -// standing_may_supersede_generation answers true for. So any consumer that classified the six +// standing_may_supersede_generation answers true for. So any consumer that classified the seven // together -- at the grain of one wrapping arm rather than per cause -- would hand an identity // collision the one standing permitted to overwrite a newer generation. Both colliding records are // legitimate and the coincidence is honest, so that is a valid generation destroyed by a reader's @@ -75,7 +75,7 @@ import gunbc.scm.load_standing { // in a node position: inside the model the reference types make it unwritable, but a document is // bytes and can say anything, so the wall there is a typed refusal rather than a constructor. // -// ObjectTableObjectCollision is NOT that, and conflating it with the five is the error this type +// ObjectTableObjectCollision is NOT that, and conflating it with the other SIX is the error this type // exists to prevent. Two distinct records claiming one structural locator is an honest hash // coincidence between two legitimate objects, not damage to either. It is a limitation of the // locator's representation, and the remedy is to widen the locator -- never to discard a generation. diff --git a/dag/gunbc/scm/supersession.dag b/dag/gunbc/scm/supersession.dag index baa9e82d3f0..c544ef7862a 100644 --- a/dag/gunbc/scm/supersession.dag +++ b/dag/gunbc/scm/supersession.dag @@ -157,20 +157,26 @@ fn observe_generation( // In `gunbc.scm.object_table_json`: `object_table_load_standing`. // // THE PERMITTING STANDING IS ONE, AND IT IS OWNED BY NO ONE MODULE. `LoadDocumentMalformed` alone -// maps to `none` in `standing_permission_refusal`; every other arm names a refusal. Three of the -// five producers can answer with it: `closure_document_load_standing`, -// `repository_envelope_load_standing` for its member-read and target-reference modes, and -// `object_table_load_standing` for six of its seven causes. No single declaration or module is -// the permitting surface. +// maps to `none` in `standing_permission_refusal`; every other arm names a refusal. The five +// producers partition three ways against it, and the partition is by AUTHORITY rather than by +// whether the value can come back out: +// DECIDE IT, selecting it from a refusal cause -- `closure_document_load_standing`, +// `repository_envelope_load_standing` for its member-read and target-reference modes, and +// `object_table_load_standing` for six of its seven causes. +// RELAY IT WITHOUT DECIDING -- `closure_document_standing` returns it from its refused-document +// branch. It is a fourth return route and not a fourth decision authority, so counting it +// beside the three would overstate how many places the policy is actually made. +// CANNOT RETURN IT -- `loaded_closure_standing`. // -// WHAT THAT LEAVES STANDING. The success path is outside the permitting relation by construction: -// `loaded_closure_standing`'s three arms are `LoadComplete`, `LoadPartial` and -// `LoadRequiresNodeAtAuthoredSource`, none of which permits, so no load that SUCCEEDED can -// authorize destroying what it read, and a load whose requirement sits on a file is refused for -// its own named reason rather than by falling through. The collision arm is the load-bearing one: -// `ObjectTableObjectCollision` reaches `LoadIdentityCollision` and not `LoadDocumentMalformed`, -// because the permitting standing must not absorb a cause meaning "two objects claimed one -// identity". +// WHAT THAT LEAVES STANDING, at arm grain rather than at the grain of "success". The +// `ClosureDocLoaded` branch cannot authorize supersession: `loaded_closure_standing` returns only +// `LoadComplete`, `LoadPartial` or `LoadRequiresNodeAtAuthoredSource`, and none permits. The arm +// is named rather than the word "succeeded" because this module deliberately separates a document +// that DECODED from one whose standing answers `standing_loaded` true -- a requirement sitting on +// authored source decodes fine and is not loaded -- and a sentence about success blurs exactly +// that distinction. The collision arm is the other load-bearing one: `ObjectTableObjectCollision` +// reaches `LoadIdentityCollision` and not `LoadDocumentMalformed`, because the permitting standing +// must not absorb a cause meaning "two objects claimed one identity". // // CONSUMERS BEFORE THIS MODULE: none in production. The permitting predicate had witnesses and no // caller, so the hazard was not live -- it was pre-positioned, waiting for the first actuator to @@ -182,14 +188,17 @@ fn observe_generation( // total-but-blind class. That symbol no longer exists in the tree. The specimen stands as a // historical record and is not counted in the five above. // -// WHAT THE AUDIT DOES NOT ESTABLISH. One producer today is not a bound on producers tomorrow, and +// WHAT THE AUDIT DOES NOT ESTABLISH. The current producer population is not a bound on producers +// tomorrow, and // that is precisely why the destination now demands evidence instead of trusting a cause: a new // classifier reaches `supersede_head_slot` on the same terms as the old one, and neither can // supply a generation it did not read. // // THE PERMISSION ARM, DESCENDING RATHER THAN ANSWERING true/false. This is the same relation the // deleted Bool encoded, and the difference is that a refusal now names which standing refused it, so -// a sixth standing fails to compile HERE rather than inheriting whichever answer a wildcard gave it. +// a NEW standing fails to compile HERE rather than inheriting whichever answer a wildcard gave it. +// (This said "a sixth standing" while the coproduct already had six arms -- the count named the +// population that existed when the sentence was written, which is what makes a counted claim rot.) fn standing_permission_refusal(standing: LoadStanding) -> SupersessionRefusalCause? { match standing { LoadDocumentMalformed => none diff --git a/dag/test/claim/scm/scm_load_standing_witness_test.dag b/dag/test/claim/scm/scm_load_standing_witness_test.dag index ca76771b1cc..b273e85c40c 100644 --- a/dag/test/claim/scm/scm_load_standing_witness_test.dag +++ b/dag/test/claim/scm/scm_load_standing_witness_test.dag @@ -174,13 +174,20 @@ test fn a_partial_load_is_never_supersedable() -> Bool { && !ls_permits(standing: LoadComplete) } -// MALFORMED IS THE SOLE SUPERSEDABLE STANDING, and the three refusals are refused for three -// different reasons rather than by one rule. If this were the empty set the negative tests above -// would pass vacuously. +// MALFORMED IS THE SOLE SUPERSEDABLE STANDING, and each non-permitting standing is refused for its +// own reason rather than by one rule. If this were the empty set the negative tests above would pass +// vacuously. +// +// THE CLAIM NAMES THE WHOLE POPULATION, SO IT MUST CHECK IT. This said "the three refusals" and +// checked two, omitting `LoadRequiresNodeAtAuthoredSource` -- which meant the standing added by this +// very branch could have permitted replacement with the claim still green. LoadComplete and +// LoadPartial are checked by a_partial_load_is_never_supersedable above; the remaining three are +// checked here, so the five are covered between the pair rather than by either alone. test fn malformed_is_the_only_standing_that_permits_replacement() -> Bool { ls_permits(standing: LoadDocumentMalformed) && !ls_permits(standing: LoadUnsupportedProtocol) && !ls_permits(standing: LoadIdentityCollision) + && !ls_permits(standing: LoadRequiresNodeAtAuthoredSource) && !standing_loaded(standing: LoadDocumentMalformed) } diff --git a/dag/test/claim/scm/scm_supersession_witness_test.dag b/dag/test/claim/scm/scm_supersession_witness_test.dag index c35f8fbbf7f..51dfd6c89c7 100644 --- a/dag/test/claim/scm/scm_supersession_witness_test.dag +++ b/dag/test/claim/scm/scm_supersession_witness_test.dag @@ -201,15 +201,23 @@ test fn proposing_the_generation_already_installed_refuses() -> Bool { && supersession_applied(outcome: sup_admitting_outcome()) } -// THE FOUR NON-PERMITTING STANDINGS EACH REFUSE, AND THE REFUSAL NAMES WHICH ONE. A collapsed -// permission arm would satisfy the first conjunct and fail the second: `StandingDoesNotPermit` -// carrying the wrong standing sends a caller to the wrong remedy -- fetch a newer reader when the -// real answer was supply the missing objects. +// EVERY NON-PERMITTING STANDING REFUSES, AND THE REFUSAL NAMES WHICH ONE. A collapsed permission arm +// would satisfy the first conjunct and fail the second: `StandingDoesNotPermit` carrying the wrong +// standing sends a caller to the wrong remedy -- fetch a newer reader when the real answer was +// supply the missing objects. +// +// THE CONJUNCTION IS THE WHOLE POPULATION, and it was not: this claim's name quantifies over EVERY +// non-permitting standing while it checked four of the five, so `LoadRequiresNodeAtAuthoredSource` +// could have collapsed to the permitting arm with this claim still green. A universally named claim +// that enumerates a subset is worse than a narrower name, because the gap is invisible at the call +// site. `standing_name` below already carried all six arms, which is what makes the omission a +// stale enumeration rather than a missing vocabulary. test fn every_non_permitting_standing_refuses_and_says_which() -> Bool { standing_refusal_is_itself(standing: LoadComplete) && standing_refusal_is_itself(standing: LoadPartial) && standing_refusal_is_itself(standing: LoadUnsupportedProtocol) && standing_refusal_is_itself(standing: LoadIdentityCollision) + && standing_refusal_is_itself(standing: LoadRequiresNodeAtAuthoredSource) && supersession_applied(outcome: sup_admitting_outcome()) }