diff --git a/dag/gunbc/fabric/fabric_m0_commit.dag b/dag/gunbc/fabric/fabric_m0_commit.dag new file mode 100644 index 00000000000..3d839465aab --- /dev/null +++ b/dag/gunbc/fabric/fabric_m0_commit.dag @@ -0,0 +1,235 @@ +module gunbc.fabric_m0_commit + +import std.types { List, NonEmptyStr } +import std.content_hash { Sha256Digest } +import std.scoped_authorization { AttemptIdentity } +import product.fabric.identity { WorkKey } +import product.fabric.work { OutputObligationsResolution } +import std.durable_compare_and_set { CasOutcome } +import gunbc.fabric_m0_completion { + ComputationOutcome, + CommittedOutput, + CommitManifest, + OriginObservation, + CommitManifestSubject, + ManifestMember, + OriginReadback, + ReadbackConfirmed, + ReadbackAbsent, + ReadbackUnknown, + JobPublication, + admit_job_publication, +} + +// The commit boundary: it turns what the STORE said about the manifest slot into the readback the +// completion authority consumes, and it is the only place allowed to make that translation. +// +// WHY THIS LAYER EXISTS AT ALL rather than the caller handing a readback straight to +// admit_job_publication: the completion authority takes an OriginReadback as a given, so somebody +// has to derive one honestly from a real conditional write. Doing that at the call site would put +// the timeout rule below in every caller, which is the second-representation failure -- and the +// arm most likely to be got wrong is the one that costs nothing to get wrong locally. + +// A store answer and the ABSENCE of a store answer are different facts. +// +// std.durable_compare_and_set's CasOutcome distinguishes committed, precondition-failed and +// store-refused, and every one of those is something the store SAID. A request that timed out or +// whose acknowledgement was lost obtained none of them, so rendering it as any CasOutcome would +// manufacture a store verdict to fit the branches available -- fabricating the one fact the caller +// most needs to not be invented. It is a sibling of the outcome, never an arm of it. +type CommitTransportOutcome + = StoreAnswered { slot: NonEmptyStr, outcome: CasOutcome } + | StoreDidNotAnswer { slot: NonEmptyStr, cause: NonEmptyStr } + +// THE SLOT AN ANSWER IS ABOUT, carried so it can be compared with the slot we asked about. +// +// std.durable_compare_and_set declares this boundary rather than closing it: its CasSlotVersion +// deliberately drops the key, and it records that an unkeyed observation describes NO slot, so +// cas_decide never reads attempt.key and an observation read from slot B still commits to slot A +// when the generations agree. It names the realization as the owner of that relation, and this +// module is that realization's boundary, so the comparison lives here or nowhere. +// +// One authority for the slot name: the key is DERIVED from the attempt rather than passed beside +// it, because a caller-supplied key next to a caller-supplied attempt is two spellings of one fact +// and they can disagree -- which is the shape the CAS module already had to correct once. +// The namespace is RECEIVED, never spelled here. A versioned namespace is a binding decision owned +// by whoever selects and configures the durable origin, so a literal prefix in this fold would be +// business policy sitting inside an interface -- the layer inversion DESIGN 3 names, whose tell is +// exactly this: a literal carried where a parameter belongs. +// +// What stays derived is the KEY, because that is the part with a correctness argument: taking a +// caller-supplied key beside a caller-supplied attempt would be two spellings of one fact that can +// disagree. Receiving the namespace and deriving the key from it keeps one authority for the +// derivation while leaving the namespace free to be versioned, re-homed, or bound to an on-fleet +// origin without touching this module. +type CommitNamespace = NonEmptyStr where brand("CommitNamespace") + +fn commit_slot_key(namespace: CommitNamespace, attempt: AttemptIdentity) -> NonEmptyStr { + namespace + "/" + attempt +} + +fn transport_slot(transport: CommitTransportOutcome) -> NonEmptyStr { + match transport { + StoreAnswered { slot: s, outcome: _ } => s + StoreDidNotAnswer { slot: s, cause: _ } => s + } +} + +// A foreign-slot answer is UNKNOWN about our object, never absence and never confirmation. +// +// It is not a softer refusal: an answer about someone else's slot carries no information about +// ours, so reporting absence would assert something nobody observed, and reporting confirmation +// would be the wrong-slot commit this check exists to stop. Unknown with a cause naming the +// mismatch is the honest state, and it cannot publish success. +fn slot_mismatch_readback(expected: NonEmptyStr, actual: NonEmptyStr) -> OriginReadback { + ReadbackUnknown { cause: "store answered about a different slot: expected " + expected + ", answer names " + actual } +} + +// What an independent reader saw at the manifest slot afterwards, kept separate from what the write +// call returned. A write that reported success and a read that confirms the intended bytes are two +// observations, and only the second establishes the object is there to be recovered. +// WHERE A CONFIRMING READ CAME FROM, because a read that did not reach the durable origin cannot +// establish durability no matter how correct its bytes are. +// +// This closes the hole that would hollow out the milestone: the local cache and the durable origin +// address the SAME immutable object by the SAME content key, so a cache hit and an origin read are +// byte-identical and indistinguishable once the digest is in hand. Without a source, "we read H back +// and it matched" is satisfied by reading the copy on the machine that is about to be lost -- and +// the job publishes success for an output that exists nowhere else. Same content identity, +// different AUTHORITY. +// +// HONEST RUNG: this is a declared boundary, not a construction. DurableOriginRead is freely +// authorable here, exactly like the readback-provenance assertion std.materialization_provider +// already records against itself, and for the same reason -- confinement of the constructor to the +// modeled read is not available until a byte-owning origin exists. What changes is that the fact is +// now STATABLE and REFUSABLE rather than absent: before this, there was nowhere to even record that +// a confirmation came from a cache. +type ReadSource + = DurableOriginRead + | DisposableCacheRead { provider: NonEmptyStr } + +type ManifestReadbackObservation + = ManifestRead { observed_digest: Sha256Digest, source: ReadSource } + | ManifestNotFound + | ManifestReadUnavailable { cause: NonEmptyStr } + +// THE TRANSLATION, and the only rule in this module with teeth. +// +// THE READ DECIDES WHAT DURABLE STATE EXISTS; the write outcome decides only what happened to THIS +// invocation. Those are different questions and publication cares about the first, so the fold is +// driven by the observation and consults the transport for exactly one thing: whether "not found" +// is established or merely unobserved. +// +// An unanswered write with nothing found yields Unknown, NOT absent -- "we stopped waiting" and "we +// observed non-commitment" are different facts, and collapsing them either re-executes work that may +// already be committed or publishes over bytes nobody read. When the store DID answer, its answer +// plus a fruitless read does establish absence. +// +// CasPreconditionFailed USED TO SHORT-CIRCUIT TO ABSENT HERE, and that was wrong in the case this +// module exists to serve. A precondition failure proves only that the requested conditional +// transition did not happen on this invocation; it does not prove the desired state is missing. The +// slot key is DERIVED FROM THE ATTEMPT, so the occupant of attempt A's slot is most often A's own +// earlier write -- the lost-acknowledgement retry: A writes, the acknowledgement is lost, the retry +// asks create-if-absent, the store answers precondition-failed because A's own manifest is already +// there. Reporting that as absence fails a job whose result is committed and readable. A CONFLICTING +// occupant is still caught, one layer up: the readback digest is compared against the recorded +// manifest digest, so a foreign manifest at our slot reports a content mismatch rather than success. +fn manifest_readback(namespace: CommitNamespace, attempt: AttemptIdentity, transport: CommitTransportOutcome, observed: ManifestReadbackObservation) -> OriginReadback { + let expected = commit_slot_key(namespace: namespace, attempt: attempt) + let actual = transport_slot(transport: transport) + if (expected == actual) == false { + slot_mismatch_readback(expected: expected, actual: actual) + } else { + manifest_readback_for_slot(transport: transport, observed: observed) + } +} + +fn manifest_readback_for_slot(transport: CommitTransportOutcome, observed: ManifestReadbackObservation) -> OriginReadback { + match observed { + ManifestRead { observed_digest: d, source: src } => + match src { + DurableOriginRead => ReadbackConfirmed { observed_digest: d } + DisposableCacheRead { provider: pr } => + ReadbackUnknown { cause: "manifest read from disposable cache " + pr + ", not the durable origin; durability unestablished" } + } + ManifestReadUnavailable { cause: rc } => ReadbackUnknown { cause: rc } + ManifestNotFound => + match transport { + StoreAnswered { slot: _, outcome: _ } => ReadbackAbsent + StoreDidNotAnswer { slot: _, cause: c } => ReadbackUnknown { cause: c } + } + } +} + +// The manifest states WHAT WAS OWED; it no longer carries a read result. +fn commit_manifest(attempt: AttemptIdentity, manifest_digest: Sha256Digest, members: List) -> CommitManifest { + CommitManifest { + attempt: attempt, + manifest_digest: manifest_digest, + members: members, + } +} + +// A store that says committed is still not a recovered result: the manifest is only confirmed by an +// independent read of the intended object, so what this boundary contributes to the completion +// authority is an OBSERVATION -- the READ's verdict -- and never the write's. +// +// This is the only manifest observation the authority will find under that subject, and it is +// DERIVED here from the transport and the read rather than accepted from the caller. A caller of +// publish_after_commit supplies reads of the required objects; it cannot supply the manifest's, +// because the slot the manifest lives at is derived from the namespace and the attempt one layer +// down and a hand-supplied observation could disagree with it. +fn manifest_observation( + namespace: CommitNamespace, + attempt: AttemptIdentity, + transport: CommitTransportOutcome, + observed: ManifestReadbackObservation +) -> OriginObservation { + OriginObservation { + subject: CommitManifestSubject {}, + readback: manifest_readback(namespace: namespace, attempt: attempt, transport: transport, observed: observed), + } +} + +// The consumer: one call from a real commit attempt to a published outcome. +// +// CONSUMPTION STATE, STATED HONESTLY (DESIGN 3c). Today this function is exercised by the +// enrolled witnesses and by nothing else: no production route reaches it, so the declarations +// below are a DECLARED FRONTIER rather than a consumed model, and this annotation is here so +// that state is legible in the module rather than inferred from an import census. +// +// THE FRONTIER TRIGGER, STATED AS THE CAPABILITY THAT CONSUMES IT: a job-publication path that +// holds a run's required work and decides its ending -- the roadmap's compute-artifact-return- +// and-materialization capability, where a computation's declared outputs come back identified by +// content through a manifest the caller reads. When that path exists, it calls this function to +// turn a commit attempt into an ending, and the frontier closes. Naming a smaller artifact -- one +// caller, one job, one emitted script -- would be satisfied while the capability stayed dead. +// +// This is a consumption frontier, NOT a guarantee drop: the gate's walls are enrolled and +// executing over the population they are declared for, so no rung is being lowered by the wait. +fn publish_after_commit( + namespace: CommitNamespace, + attempt: AttemptIdentity, + computation: ComputationOutcome, + resolution: OutputObligationsResolution, + judged_work: WorkKey, + committed: List, + observations: List, + manifest_digest: Sha256Digest, + members: List, + transport: CommitTransportOutcome, + observed: ManifestReadbackObservation +) -> JobPublication { + admit_job_publication( + attempt: attempt, + computation: computation, + resolution: resolution, + judged_work: judged_work, + committed: committed, + observations: concat( + observations, + [manifest_observation(namespace: namespace, attempt: attempt, transport: transport, observed: observed)] + ), + manifest: commit_manifest(attempt: attempt, manifest_digest: manifest_digest, members: members) + ) +} diff --git a/dag/gunbc/fabric/fabric_m0_completion.dag b/dag/gunbc/fabric/fabric_m0_completion.dag new file mode 100644 index 00000000000..32d0d0c1e2e --- /dev/null +++ b/dag/gunbc/fabric/fabric_m0_completion.dag @@ -0,0 +1,460 @@ +module gunbc.fabric_m0_completion + +import std.types { List, NonEmptyStr, Bool } +import std.content_hash { Sha256Digest, content_hash_eq_cryptographic } +import std.scoped_authorization { AttemptIdentity } +import product.fabric.identity { WorkKey } +import product.fabric.work { + OutputObligation, + OutputObligationKind, + output_obligation_kind_material, + OutputObligationsResolution, + OutputObligationsResolved, + OutputObligationsUnreadable, + ResolvedOutputObligations, + OutputObligationsUnreadableCause, + WorkKeyDoesNotMatchContract, +} + +// The fabric-M0 durable-completion authority: the single function that may call a job successful. +// It is modeled before any cache exists because a cache accelerates a completion contract and +// cannot supply one — building the fast path first produces something that looks like storage +// without establishing that a successful job's required outputs outlive the worker. +// +// WHY THE DIGEST CARRIERS BELOW ARE Sha256Digest AND NOT ContentHash: std.content_hash makes +// ContentHash a family coproduct whose Fnv1a64Structural arm is a structural FINGERPRINT, minted +// for computation identity and not from transferred bytes. A union-typed field would let a +// structural fingerprint stand where a byte-integrity digest is required, and until Phase B of +// feature:content-hash-family-grounded lands, the substrate still admits cross-family `==` at +// typecheck. Pinning the family AT THE CARRIER is the same move GateRosterHash and v2.std.node.Hash +// already make for Fnv1a64Structural, and it puts this class at structurally impossible rather than +// validated: there is no constructor here that accepts a structural fingerprint as an integrity +// digest, so the confusion has no representation instead of a check that could be skipped. +// PATH-SCOPED, PER 4b(1): that reading holds on the source-to-dag acceptance path. The emitted Rust +// mirror of these carriers is a public struct with public fields (gunbc.model.population), so a +// consumer writing Rust against the seed is outside the guarantee, and a class's rung is the +// MINIMUM across its in-scope paths. Fabric-M0 has no Rust consumer today, so the rung holds NOW +// with that named degradation condition rather than absolutely. + +// SUBJECT IDENTITY COMES FROM THE CONTRACT, NOT FROM THIS GATE. +// +// An earlier revision minted RequiredArtifactId and RequiredReceiptId here and carried two parallel +// rosters. Both were this module's own coinage: the two verdict paths differed only in type name, +// so the duplication looked like domain structure while being invented vocabulary. product.fabric.work +// now owns the subject identity as OutputObligation -- one id WITH a kind -- so the rosters collapse +// to one and the gate keys by the identity the contract issued. +// +// KIND IS PART OF THE KEY, NOT DECORATION. An artifact obligation and a terminal-result obligation +// may carry the same id, so a join on id alone silently lets one discharge the other. Every lookup +// below compares id AND kind. +fn obligation_eq(a: OutputObligation, b: OutputObligation) -> Bool { + (a.id == b.id) + && (output_obligation_kind_material(kind: a.kind) == output_obligation_kind_material(kind: b.kind)) +} + +// A display name for diagnostics only. It never keys a lookup -- obligation_eq does -- so a +// collision here costs a confusing message, never a wrong verdict. +fn obligation_subject_name(o: OutputObligation) -> NonEmptyStr { + (o.id as NonEmptyStr) + " [" + output_obligation_kind_material(kind: o.kind) + "]" +} + +// What an independent reader observed at the origin, WITHOUT consulting the producer's cache. +// +// ReadbackUnknown is a third arm rather than a failure because a lost acknowledgement does not +// establish that the write failed, and an object's mere existence does not establish it is the +// intended object. Folding unknown into either decided arm is the absorbing fallback DESIGN 5 +// forbids: into failure it discards a possibly-committed result and re-executes; into success it +// publishes green over bytes nobody has read. +type OriginReadback + = ReadbackConfirmed { observed_digest: Sha256Digest } + | ReadbackAbsent + | ReadbackUnknown { cause: NonEmptyStr } + +type CommittedOutput { + obligation: OutputObligation + attempt: AttemptIdentity + recorded_digest: Sha256Digest +} + +// The computation's own verdict, kept separate from every storage fact below it. +type ComputationOutcome + = ComputationSucceeded + | ComputationFailed { cause: NonEmptyStr } + +// Per-obligation verdicts, and the three-valued space the roster fold lives in. +// ObligationUnenumerable is the roster-grain arm: not one obligation that failed, but a population +// nobody could list. It dominates the meet because a job whose obligations cannot be enumerated +// cannot be shown complete by any number of satisfied siblings -- and keeping it as a VERDICT rather +// than an early return is what lets the readable rosters still be assessed beside it. "Cannot +// enumerate this population" is not "stop examining every other population". +type ObligationVerdict + = ObligationSatisfied + | ObligationUnsatisfied { subject: NonEmptyStr, cause: NonEmptyStr } + | ObligationUndetermined { subject: NonEmptyStr, cause: NonEmptyStr } + | ObligationUnenumerable { subject: NonEmptyStr, cause: NonEmptyStr } + +// Unsatisfied DOMINATES undetermined, which is not arbitrary: an absent required object is already +// decided, so no later readback can turn the population complete, while an unknown one may still +// reconcile. Taking the meet in the other direction would park a definitely-incomplete result in +// pending forever, and taking it toward satisfied would publish success over a missing output. +fn obligation_meet(a: ObligationVerdict, b: ObligationVerdict) -> ObligationVerdict { + if obligation_rank(v: a) >= obligation_rank(v: b) { a } else { b } +} + +// One ordering, stated once. Unenumerable dominates Unsatisfied dominates Undetermined dominates +// Satisfied: a decided absence cannot be reconciled away, so it must not sit behind an unknown that +// may still resolve; and an unlistable roster outranks both because it is not even a claim about a +// particular obligation. +fn obligation_rank(v: ObligationVerdict) -> Int { + match v { + ObligationSatisfied => 0 + ObligationUndetermined { subject: _, cause: _ } => 1 + ObligationUnsatisfied { subject: _, cause: _ } => 2 + ObligationUnenumerable { subject: _, cause: _ } => 3 + } +} + +fn readback_digest_verdict(subject: NonEmptyStr, recorded: Sha256Digest, readback: OriginReadback) -> ObligationVerdict { + match readback { + ReadbackConfirmed { observed_digest: observed } => + if content_hash_eq_cryptographic(left: recorded, right: observed) { + ObligationSatisfied + } else { + ObligationUnsatisfied { subject: subject, cause: "origin readback digest differs from the committed record" } + } + ReadbackAbsent => ObligationUnsatisfied { subject: subject, cause: "required artifact absent at origin" } + ReadbackUnknown { cause: c } => ObligationUndetermined { subject: subject, cause: c } + } +} + +// THE READ IS A SEPARATE POPULATION FROM THE WRITE, JOINED HERE RATHER THAN ASSEMBLED BY THE CALLER. +// +// This shape exists because the previous one carried `readback` as a FIELD of each committed row, +// which made a readback exist for every committed object by construction: "no origin read was +// performed for this subject" had no representation, and the nearest expressible thing -- +// ReadbackUnknown -- was a value a caller had to deliberately choose. A record whose read result is +// always present and whose two compared halves are both supplied by one caller is checking a claim +// about a commit rather than the commit; it is the seeded-true fold answering "complete" over a +// population it never examined. +// +// Splitting them makes the unexamined case the DEFAULT rather than an option: an observation +// population that no reader wrote to is empty, every required subject misses its join, and the fold +// cannot reach success. Undetermined rather than unsatisfied is deliberate -- an unperformed read +// says nothing about whether the object is there, so it must reconcile, not fail. +// +// What this does NOT establish, stated plainly: nothing here proves a reader ran. That is the +// observation-to-slot boundary std.durable_compare_and_set already declares outside its guarantee, +// and no pure fold can close it. What it does establish is that the AUTHORITY performs the join, so +// a missing read is structurally distinguishable from a satisfied one instead of being absorbed. +// +// THE REMAINING GAP AND ITS TRIGGER, STATED AS A CAPABILITY. An observation's digest is still +// ASSERTED rather than computed from the bytes read back, which is the rostered class +// gunbc.recurring_failure_mode.subject_and_its_digest_as_independent_parameters -- a value and a +// summary of it standing side by side with no arm relating them. That row already declares the +// repair: derive the digest from the value at the point the value is obtained, never accept it as a +// peer parameter. It is not applied here because it is not CONSTRUCTIBLE here. +// +// NEXT-RUNG TRIGGER, AS A CAPABILITY: a function in the substrate computing SHA-256 over Bytes, so +// a digest and the bytes it names are produced by one operation. Not "a digest for the manifest" -- +// a trigger naming less than the capability is satisfied while the capability stays dead. +// +// THAT TRIGGER IS THE CLIMB, NOT A PREREQUISITE, AND THE DISTINCTION MATTERS BECAUSE TWO DIGEST +// SHAPES LOOK ALIKE. extdeps.crypto.hash sha256_digest takes HEX -- an author asserts a digest and +// nothing computes it. extdeps.tools.sha256sum sha256sum_file_digest_via_shell takes a PATH and +// COMPUTES over the bytes there, with a typed Sha256FileDigestUnavailable arm; by section 3 that is +// a handler bound to a digest shape, one of N, and it is live in +// gunbc.instruments.fabric_control_plane_live_probe. No .dag function takes Bytes and returns a +// digest, so the rostered repair is reachable exactly where the subject bytes EXIST AT A PATH. +// These subjects are stored objects, so a durable-origin readback that stages what it fetched puts +// them at a path and the chain is buildable at a mitigatable rung today. +// +// THE CONSTRAINT ON THAT REALIZATION: the digested file must be the ORIGIN readback's own output +// and never a cache copy. Computing a byte-exact digest over locally-sourced bytes would launder +// the cache-read refusal in gunbc.fabric_m0_commit manifest_readback_for_slot into a confirmation -- +// a correct computation over the wrong provenance. +// The manifest is a subject too, but it is not an obligation, so the subject is a coproduct rather +// than a string. Deriving a string key from an obligation would reintroduce exactly the collision +// product.fabric.work warns about when ids are concatenated. +type ObservationSubject + = ObligationSubject { obligation: OutputObligation } + | CommitManifestSubject {} + +fn observation_subject_eq(a: ObservationSubject, b: ObservationSubject) -> Bool { + match a { + CommitManifestSubject {} => + match b { + CommitManifestSubject {} => true + other => false + } + ObligationSubject { obligation: oa } => + match b { + ObligationSubject { obligation: ob } => obligation_eq(a: oa, b: ob) + other => false + } + } +} + +fn observation_subject_name(s: ObservationSubject) -> NonEmptyStr { + match s { + CommitManifestSubject {} => "commit-manifest" + ObligationSubject { obligation: o } => obligation_subject_name(o: o) + } +} + +type OriginObservation { + subject: ObservationSubject + readback: OriginReadback +} + +fn readback_verdict_for(observations: List, subject: ObservationSubject, recorded: Sha256Digest) -> ObligationVerdict { + let seen = observations |> filter(o => observation_subject_eq(a: o.subject, b: subject)) + let name = observation_subject_name(s: subject) + if seen.count() == 0 { + ObligationUndetermined { subject: name, cause: "no origin read is recorded for this subject" } + } else if seen.count() > 1 { + ObligationUnsatisfied { subject: name, cause: "several origin observations claim this subject; no authoritative read" } + } else { + match seen |> first { + Present { value: o } => readback_digest_verdict(subject: name, recorded: recorded, readback: o.readback) + Absent => ObligationUndetermined { subject: name, cause: "no origin read is recorded for this subject" } + } + } +} + +// AMBIGUITY REFUSES RATHER THAN RESOLVING BY POSITION. The former shape took the first match, so a +// second, conflicting or undetermined row for the same identity was discarded before any fold could +// see it -- and evidence dropped at selection cannot be recovered by preserving everything after it. +// An unconstrained list whose first element wins establishes neither a unique admitted observation +// nor an authoritative choice among a history, so this refuses and names the count. +fn committed_outputs_for(committed: List, wanted: OutputObligation, attempt: AttemptIdentity) -> List { + committed |> filter(c => obligation_eq(a: c.obligation, b: wanted) && (c.attempt == attempt)) +} + +// ONE VERDICT PATH. The artifact and receipt versions of this function differed only in type name, +// which is what made the split invented rather than modelled. +fn required_output_verdict( + committed: List, + observations: List, + members: List, + wanted: OutputObligation, + attempt: AttemptIdentity +) -> ObligationVerdict { + let name = obligation_subject_name(o: wanted) + let rows = committed_outputs_for(committed: committed, wanted: wanted, attempt: attempt) + if rows.count() == 0 { + ObligationUnsatisfied { subject: name, cause: "no committed output bound to this attempt" } + } else if rows.count() > 1 { + ObligationUnsatisfied { subject: name, cause: "several committed outputs claim this identity for one attempt; no authoritative row" } + } else { + match rows |> first { + Present { value: c } => + obligation_meet( + a: readback_verdict_for( + observations: observations, + subject: ObligationSubject { obligation: wanted }, + recorded: c.recorded_digest + ), + b: manifest_membership_verdict(members: members, subject: wanted, expected: c.recorded_digest) + ) + Absent => ObligationUnsatisfied { subject: name, cause: "no committed output bound to this attempt" } + } + } +} + +// The manifest names the committed set, so membership is CHECKED rather than asserted by a comment. +// +// Independent checks of several objects do not establish the relationships between them: without +// this, the contract said only "these objects each passed, and something labelled this attempt's +// manifest passed", never "that manifest names these objects at these contents". Reconstructing a +// result after the worker is gone needs the second. +type ManifestMember { + obligation: OutputObligation + content: Sha256Digest +} + +fn manifest_membership_verdict(members: List, subject: OutputObligation, expected: Sha256Digest) -> ObligationVerdict { + let named = members |> filter(m => obligation_eq(a: m.obligation, b: subject)) + let subject = obligation_subject_name(o: subject) + if named.count() == 0 { + ObligationUnsatisfied { subject: subject, cause: "commit manifest does not name this required object" } + } else if named.count() > 1 { + ObligationUnsatisfied { subject: subject, cause: "commit manifest names this required object more than once" } + } else { + match named |> first { + Present { value: m } => + if content_hash_eq_cryptographic(left: m.content, right: expected) { + ObligationSatisfied + } else { + ObligationUnsatisfied { subject: subject, cause: "commit manifest names a different content for this required object" } + } + Absent => ObligationUnsatisfied { subject: subject, cause: "commit manifest does not name this required object" } + } + } +} + +type CommitManifest { + attempt: AttemptIdentity + manifest_digest: Sha256Digest + members: List +} + +fn manifest_verdict(manifest: CommitManifest, observations: List, attempt: AttemptIdentity) -> ObligationVerdict { + if (manifest.attempt == attempt) == false { + ObligationUnsatisfied { subject: "commit-manifest", cause: "manifest is bound to a different attempt" } + } else { + readback_verdict_for(observations: observations, subject: CommitManifestSubject {}, recorded: manifest.manifest_digest) + } +} + +// Each roster contributes verdicts whether or not its SIBLING could be read. +// THE ROSTER COMES FROM A RESOLUTION, NOT FROM THE CALLER. +// +// This is the repair for the defect that outlived every other one here: the caller used to CHOOSE +// between a declared roster and an unreadable one, so a caller that never read the obligations could +// declare empty and mint terminal success. product.fabric.work now seals ResolvedOutputObligations, +// whose only mint is resolve_output_obligations, so an empty roster is a RESOLVED value and cannot +// be asserted at this gate. +// +// WHAT THE SEAL DOES NOT CLOSE, and this function does. A sealed resolution is unforgeable but +// still SUBSTITUTABLE: a caller may hold a perfectly valid resolution for work B while asking this +// gate to judge work A. The seal binds a roster to its own contract; only the gate knows which work +// it was asked about, so the work_key comparison lives here and nowhere else. +fn roster_verdicts( + resolution: OutputObligationsResolution, + judged_work: WorkKey, + committed: List, + observations: List, + members: List, + attempt: AttemptIdentity +) -> List { + match resolution { + OutputObligationsUnreadable { contract: c, cause: cause } => + [ObligationUnenumerable { + subject: (c as NonEmptyStr), + cause: unreadable_cause_text(cause: cause) + }] + OutputObligationsResolved { value: resolved } => + if (resolved.work_key == judged_work) == false { + [ObligationUnenumerable { + subject: (resolved.contract as NonEmptyStr), + cause: "resolved obligations are bound to a different work than the one being judged" + }] + } else { + resolved.required + |> map(wanted => required_output_verdict( + committed: committed, + observations: observations, + members: members, + wanted: wanted, + attempt: attempt + )) + } + } +} + +// The refusal is typed at the carrier, so this renders it rather than re-deciding it. +fn unreadable_cause_text(cause: OutputObligationsUnreadableCause) -> NonEmptyStr { + match cause { + WorkKeyDoesNotMatchContract {} => "a foreign work key cannot seal this contract's roster" + } +} + +fn storage_assessment( + attempt: AttemptIdentity, + resolution: OutputObligationsResolution, + judged_work: WorkKey, + committed: List, + observations: List, + manifest: CommitManifest +) -> List { + concat( + roster_verdicts( + resolution: resolution, + judged_work: judged_work, + committed: committed, + observations: observations, + members: manifest.members, + attempt: attempt + ), + [manifest_verdict(manifest: manifest, observations: observations, attempt: attempt)] + ) +} + +fn verdict_is_unresolved(v: ObligationVerdict) -> Bool { + match v { + ObligationSatisfied => false + ObligationUnsatisfied { subject: _, cause: _ } => true + ObligationUndetermined { subject: _, cause: _ } => true + ObligationUnenumerable { subject: _, cause: _ } => true + } +} + +fn unresolved_obligations(verdicts: List) -> List { + verdicts |> filter(v => verdict_is_unresolved(v: v)) +} + +fn verdicts_meet(verdicts: List) -> ObligationVerdict { + fold(verdicts, init: ObligationSatisfied, f: (acc, v) => obligation_meet(a: acc, b: v)) +} + +// PublishObligationsUnreadable IS RE-HOMED, NOT DELETED. That arm used to be reached by a +// caller-supplied unreadable roster. That state is now unwritable ON THE SOURCE-TO-DAG PATH, which +// is the honest scope and not an absolute claim (4b(1); the Rust mirror caveat above applies here +// too): product.fabric.work makes +// output_obligations a REQUIRED field of the contract, so "the roster could not be read" has no +// producer at all. The arm survives because it is still reachable from two states that are real -- +// a typed OutputObligationsUnreadable from resolve_output_obligations, and a resolution bound to a +// different work than the one being judged. Deleting it would have discarded a live refusal; +// keeping it uninhabited would have been the decoration DESIGN 4b calls worse than absent. +type JobPublication + = PublishSuccess { attempt: AttemptIdentity, manifest_digest: Sha256Digest } + | PublishComputationFailed { attempt: AttemptIdentity, cause: NonEmptyStr, unresolved: List } + | PublishFinalizationFailed { attempt: AttemptIdentity, subject: NonEmptyStr, cause: NonEmptyStr, unresolved: List } + | PublishPendingReconciliation { attempt: AttemptIdentity, subject: NonEmptyStr, cause: NonEmptyStr, unresolved: List } + | PublishObligationsUnreadable { attempt: AttemptIdentity, subject: NonEmptyStr, cause: NonEmptyStr, unresolved: List } + +// THE SUCCESS AUTHORITY. Total over five outcomes. +// +// SHORT-CIRCUIT THE CHOICE OF OUTCOME, NEVER THE COLLECTION OF EVIDENCE. The storage assessment is +// computed FIRST and unconditionally -- it is a pure fold over supplied observations, so computing +// it neither performs a read nor delays recognising the build failure -- and only then does the +// computation verdict decide the published outcome. Failure dominance is therefore a property of +// the RESULT rather than of skipping the assessment, which is what the earlier shape got wrong: a +// failed build returned before any obligation was examined, so a job that failed to compile AND had +// an unresolved receipt upload reported the compile error and silently dropped the reconciliation +// work. Both facts are true and only the second says what to do next. +// +// Success remains the last arm and is reachable only from a wholly satisfied assessment. +fn admit_job_publication( + attempt: AttemptIdentity, + computation: ComputationOutcome, + resolution: OutputObligationsResolution, + judged_work: WorkKey, + committed: List, + observations: List, + manifest: CommitManifest +) -> JobPublication { + let verdicts = storage_assessment( + attempt: attempt, + resolution: resolution, + judged_work: judged_work, + committed: committed, + observations: observations, + manifest: manifest + ) + let unresolved = unresolved_obligations(verdicts: verdicts) + match computation { + ComputationFailed { cause: c } => + PublishComputationFailed { attempt: attempt, cause: c, unresolved: unresolved } + ComputationSucceeded => + match verdicts_meet(verdicts: verdicts) { + ObligationSatisfied => PublishSuccess { attempt: attempt, manifest_digest: manifest.manifest_digest } + ObligationUnsatisfied { subject: s, cause: c } => + PublishFinalizationFailed { attempt: attempt, subject: s, cause: c, unresolved: unresolved } + ObligationUndetermined { subject: s, cause: c } => + PublishPendingReconciliation { attempt: attempt, subject: s, cause: c, unresolved: unresolved } + ObligationUnenumerable { subject: s, cause: c } => + PublishObligationsUnreadable { attempt: attempt, subject: s, cause: c, unresolved: unresolved } + } + } +} diff --git a/dag/test/claim/fabric_m0_commit_witness_test.dag b/dag/test/claim/fabric_m0_commit_witness_test.dag new file mode 100644 index 00000000000..bc7edb91830 --- /dev/null +++ b/dag/test/claim/fabric_m0_commit_witness_test.dag @@ -0,0 +1,316 @@ +module test.claim.fabric_m0_commit_witness + +import std.types { Bool, List, NonEmptyStr } +import std.content_hash { Sha256Digest, Sha256DigestHex } +import std.scoped_authorization { AttemptIdentity } +import std.measure { hardware_thread_count } +import product.fabric.identity { WorkKey } +import product.fabric.envelope { unstated_envelope } +import product.fabric.isolation { shared_kernel_sandbox_profile } +import product.fabric.work { + WorkContract, WorkStep, ReceiptSatisfiable, ExecutionRequirements, + Shape, HardRequirements, ControlPlaneCapacity, derive_work_key, + OutputObligation, OutputArtifactObligation, TerminalResultObligation, + DeclaredOutputObligations, + resolve_output_obligations, ObligationsJudgedAtJobStart, + OutputObligationsResolution, +} +import std.durable_compare_and_set { CasStoreRefused, CasUnreadableReadRefused, CasPreconditionFailed, ExpectSlotAbsent, CasReadableAbsent } +import gunbc.fabric_m0_completion { + ObservationSubject, + ObligationSubject, + CommitManifestSubject, + ComputationSucceeded, + CommittedOutput, + ManifestMember, + ReadbackConfirmed, + OriginObservation, + JobPublication, + PublishSuccess, + PublishFinalizationFailed, + PublishPendingReconciliation, +} +import gunbc.fabric_m0_commit { + CommitTransportOutcome, + StoreAnswered, + StoreDidNotAnswer, + ManifestReadbackObservation, + ManifestRead, + ReadSource, + DurableOriginRead, + DisposableCacheRead, + ManifestNotFound, + ManifestReadUnavailable, + publish_after_commit, + CommitNamespace, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +fn digest_a() -> Sha256Digest { + Sha256Digest { hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Sha256DigestHex } +} + +fn the_attempt() -> AttemptIdentity { + "attempt-1" as AttemptIdentity +} + +fn members() -> List { + [ + ManifestMember { obligation: build_output(), content: digest_a() }, + ManifestMember { obligation: execution_receipt(), content: digest_a() }, + ] +} + +fn committed_both() -> List { + [ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ] +} + +// The object reads. The MANIFEST's observation is deliberately not here: the commit boundary +// derives it from the transport and the read, so a caller cannot supply one that disagrees with the +// slot the manifest actually lives at. +fn object_reads() -> List { + [ + OriginObservation { subject: ObligationSubject { obligation: build_output() }, readback: ReadbackConfirmed { observed_digest: digest_a() } }, + OriginObservation { subject: ObligationSubject { obligation: execution_receipt() }, readback: ReadbackConfirmed { observed_digest: digest_a() } }, + ] +} + +fn commit(transport: CommitTransportOutcome, observed: ManifestReadbackObservation) -> JobPublication { + publish_after_commit( + namespace: the_namespace(), + attempt: the_attempt(), + computation: ComputationSucceeded, + resolution: the_resolution(), + judged_work: the_work(), + committed: committed_both(), + observations: object_reads(), + manifest_digest: digest_a(), + members: members(), + transport: transport, + observed: observed, + ) +} + +fn build_output() -> OutputObligation { + OutputObligation { id: "build-output", kind: OutputArtifactObligation {} } +} + +fn execution_receipt() -> OutputObligation { + OutputObligation { id: "execution-receipt", kind: TerminalResultObligation {} } +} + +fn fixture_requirements() -> ExecutionRequirements { + ExecutionRequirements { + capacity_class: ControlPlaneCapacity, + shape: Shape { + hard: HardRequirements { threads: hardware_thread_count(8) }, + envelope: unstated_envelope(), + }, + capabilities: "linux-arm64", + trust_domain: "fleet-owned", + isolation: shared_kernel_sandbox_profile(), + } +} + +fn the_contract() -> WorkContract { + WorkContract { + program: "gunbc.fabric-m0.program:1", + steps: [WorkStep { step_id: "fabric-m0", resumability: ReceiptSatisfiable }], + source: "source:1", + inputs: "inputs:1", + runtime_closure: "runtime:1", + effect_contract: "effects:1", + output_contract: "outputs:1", + output_obligations: DeclaredOutputObligations { required: [build_output(), execution_receipt()] }, + } +} + +fn the_work() -> WorkKey { + derive_work_key(contract: the_contract(), requirements: fixture_requirements()) +} + +fn the_resolution() -> OutputObligationsResolution { + resolve_output_obligations( + contract: the_contract(), + requirements: fixture_requirements(), + work_key: the_work(), + judgment: ObligationsJudgedAtJobStart, + ) +} + +fn the_namespace() -> CommitNamespace { + "fabric-m0/v1/commit" as CommitNamespace +} + +fn our_slot() -> NonEmptyStr { + "fabric-m0/v1/commit/attempt-1" +} + +fn store_refused() -> CommitTransportOutcome { + StoreAnswered { slot: our_slot(), outcome: CasStoreRefused { cause: CasUnreadableReadRefused { detail: "read refused" } } } +} + +// THE POSITIVE CONTROL: a store that answered and a read that found the intended manifest. +test fn an_answered_commit_confirmed_by_readback_publishes_success() -> Bool { + match commit(transport: store_refused(), observed: ManifestRead { observed_digest: digest_a(), source: DurableOriginRead }) { + PublishSuccess { attempt: _, manifest_digest: _ } => true + other => false + } +} + +// THE RULE WITH TEETH. A write whose acknowledgement never arrived, with the object not yet visible, +// is PENDING -- not a failure. Rendering it absent would re-execute work that may already be +// committed, and rendering it success would green over bytes nobody read. +test fn an_unanswered_write_with_no_object_yet_is_pending() -> Bool { + match commit( + transport: StoreDidNotAnswer { slot: our_slot(), cause: "commit acknowledgement lost" }, + observed: ManifestNotFound, + ) { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AND THE SAME UNANSWERED WRITE RECONCILES once an independent read finds the intended manifest, so +// the pending arm above is a state that resolves rather than a dead end. +test fn an_unanswered_write_reconciles_when_the_object_is_read_back() -> Bool { + match commit( + transport: StoreDidNotAnswer { slot: our_slot(), cause: "commit acknowledgement lost" }, + observed: ManifestRead { observed_digest: digest_a(), source: DurableOriginRead }, + ) { + PublishSuccess { attempt: _, manifest_digest: _ } => true + other => false + } +} + +// A READ THAT COULD NOT RUN IS ALSO UNKNOWN, never absence: not reaching the store says nothing +// about what the store holds. +test fn an_unavailable_readback_is_pending_not_absent() -> Bool { + match commit(transport: store_refused(), observed: ManifestReadUnavailable { cause: "origin unreachable" }) { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AN OBJECT THE READER LOOKED FOR AND DID NOT FIND, after a store that DID answer, is decided +// against us -- this is the arm that must not be confused with the two above. +test fn an_answered_commit_with_no_object_prevents_success() -> Bool { + match commit(transport: store_refused(), observed: ManifestNotFound) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// THE DOUBLE UNKNOWN, and the arm a mutation proved nothing covered: the write was never +// acknowledged AND the confirming read could not run. Neither observation says the object is +// absent, so this is the case where collapsing to absence would re-execute committed work while +// looking maximally conservative. It is a separate witness from the store-answered one above +// because they are different branches, and covering one left the other free to lie. +test fn an_unanswered_write_with_an_unavailable_read_is_pending() -> Bool { + match commit( + transport: StoreDidNotAnswer { slot: our_slot(), cause: "commit acknowledgement lost" }, + observed: ManifestReadUnavailable { cause: "origin unreachable" }, + ) { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AN ANSWER ABOUT SOMEONE ELSE'S SLOT IS NOT EVIDENCE ABOUT OURS. The CAS authority declares this +// relation outside its own guarantee -- its observation carries no key, so generations agreeing is +// enough to commit to the wrong slot -- and names the realization as the owner. This is that owner. +test fn an_answer_about_a_foreign_slot_cannot_publish_success() -> Bool { + match commit( + transport: StoreAnswered { + slot: "fabric-m0/v1/commit/attempt-9", + outcome: CasStoreRefused { cause: CasUnreadableReadRefused { detail: "read refused" } }, + }, + observed: ManifestRead { observed_digest: digest_a(), source: DurableOriginRead }, + ) { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AND A FOREIGN-SLOT ANSWER IS NOT REPORTED AS ABSENCE EITHER, so the check above is not satisfied +// by an implementation that simply fails everything it cannot match. +test fn a_foreign_slot_answer_is_not_reported_as_absence() -> Bool { + match commit( + transport: StoreDidNotAnswer { slot: "fabric-m0/v1/commit/attempt-9", cause: "commit acknowledgement lost" }, + observed: ManifestNotFound, + ) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: _ } => false + other => true + } +} + +fn digest_b() -> Sha256Digest { + Sha256Digest { hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as Sha256DigestHex } +} + +fn precondition_failed() -> CommitTransportOutcome { + StoreAnswered { + slot: our_slot(), + outcome: CasPreconditionFailed { expected: ExpectSlotAbsent, observed: CasReadableAbsent }, + } +} + +// THE LOST-ACKNOWLEDGEMENT RETRY, and the case that made the old precondition branch wrong. The +// slot key is derived from the attempt, so the occupant of our slot after a lost acknowledgement is +// most often OUR OWN earlier write: A writes, the ack is lost, the retry asks create-if-absent, and +// the store answers precondition-failed because A's manifest is already there. Reporting that as +// absence fails a job whose result is committed and readable. +test fn a_precondition_failure_reconciles_when_our_manifest_is_read_back() -> Bool { + match commit(transport: precondition_failed(), observed: ManifestRead { observed_digest: digest_a(), source: DurableOriginRead }) { + PublishSuccess { attempt: _, manifest_digest: _ } => true + other => false + } +} + +// AND A FOREIGN OCCUPANT IS STILL A CONFLICT, so the repair above does not turn precondition failure +// into a blanket success. The reconciliation is earned by the digest matching, not by the branch. +test fn a_precondition_failure_with_a_conflicting_manifest_prevents_success() -> Bool { + match commit(transport: precondition_failed(), observed: ManifestRead { observed_digest: digest_b(), source: DurableOriginRead }) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AND A PRECONDITION FAILURE WITH NOTHING AT THE SLOT IS STILL ABSENCE: the store answered, so a +// fruitless read establishes non-commitment rather than leaving it unknown. +test fn a_precondition_failure_with_no_object_is_absence() -> Bool { + match commit(transport: precondition_failed(), observed: ManifestNotFound) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// A CACHE HIT IS NOT A DURABILITY PROOF, and this is the wall that keeps the milestone from being +// hollow. The cache and the origin address the same immutable object by the same content key, so +// the bytes and the digest are identical and only the SOURCE separates them. Reading our own +// manifest back off the node that is about to be lost must not publish success. +test fn a_manifest_read_from_the_local_cache_cannot_publish_success() -> Bool { + match commit( + transport: store_refused(), + observed: ManifestRead { observed_digest: digest_a(), source: DisposableCacheRead { provider: "srv1-nvme" } }, + ) { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +// AND THE SAME BYTES FROM THE ORIGIN DO PUBLISH, so the refusal above is attributable to the source +// and not to anything about the content. Identical digest, identical transport, opposite verdict. +test fn the_same_manifest_read_from_the_origin_publishes_success() -> Bool { + match commit( + transport: store_refused(), + observed: ManifestRead { observed_digest: digest_a(), source: DurableOriginRead }, + ) { + PublishSuccess { attempt: _, manifest_digest: _ } => true + other => false + } +} diff --git a/dag/test/claim/fabric_m0_completion_witness_test.dag b/dag/test/claim/fabric_m0_completion_witness_test.dag new file mode 100644 index 00000000000..9d442d3146a --- /dev/null +++ b/dag/test/claim/fabric_m0_completion_witness_test.dag @@ -0,0 +1,700 @@ +module test.claim.fabric_m0_completion_witness + +import std.types { Bool, List, String, NonEmptyStr } +import std.measure { hardware_thread_count } +import std.content_hash { Sha256Digest, Sha256DigestHex } +import std.scoped_authorization { AttemptIdentity } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import product.fabric.identity { WorkKey } +import product.fabric.envelope { unstated_envelope } +import product.fabric.isolation { shared_kernel_sandbox_profile } +import product.fabric.work { + WorkContract, WorkStep, ReceiptSatisfiable, ExecutionRequirements, + Shape, HardRequirements, ControlPlaneCapacity, derive_work_key, + OutputObligation, OutputArtifactObligation, TerminalResultObligation, + DeclaredOutputObligations, + resolve_output_obligations, ObligationsJudgedAtJobStart, + OutputObligationsResolution, +} +import gunbc.fabric_m0_completion { + ComputationOutcome, + ComputationSucceeded, + ComputationFailed, + OriginReadback, + ReadbackConfirmed, + ReadbackAbsent, + ReadbackUnknown, + ObservationSubject, + ObligationSubject, + CommitManifestSubject, + OriginObservation, + CommittedOutput, + CommitManifest, + JobPublication, + PublishSuccess, + PublishComputationFailed, + PublishFinalizationFailed, + PublishObligationsUnreadable, + PublishPendingReconciliation, + ObligationVerdict, + ManifestMember, + admit_job_publication, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// THE TWO SUBJECTS EXERCISE BOTH KINDS, which the earlier two-roster shape could not: an artifact +// obligation and a terminal-result obligation are different owed subjects even under one id, so a +// fixture that only ever used one kind could not tell a kind-blind join from a correct one. +fn build_output() -> OutputObligation { + OutputObligation { id: "build-output", kind: OutputArtifactObligation {} } +} + +fn execution_receipt() -> OutputObligation { + OutputObligation { id: "execution-receipt", kind: TerminalResultObligation {} } +} + +// The same id under the OTHER kind. Nothing may let this discharge build_output(). +fn build_output_as_terminal() -> OutputObligation { + OutputObligation { id: "build-output", kind: TerminalResultObligation {} } +} + +fn fixture_requirements() -> ExecutionRequirements { + ExecutionRequirements { + capacity_class: ControlPlaneCapacity, + shape: Shape { + hard: HardRequirements { threads: hardware_thread_count(8) }, + envelope: unstated_envelope(), + }, + capabilities: "linux-arm64", + trust_domain: "fleet-owned", + isolation: shared_kernel_sandbox_profile(), + } +} + +fn contract_owing(required: List) -> WorkContract { + WorkContract { + program: "gunbc.fabric-m0.program:1", + steps: [WorkStep { step_id: "fabric-m0", resumability: ReceiptSatisfiable }], + source: "source:1", + inputs: "inputs:1", + runtime_closure: "runtime:1", + effect_contract: "effects:1", + output_contract: "outputs:1", + output_obligations: DeclaredOutputObligations { required: required }, + } +} + +fn the_work() -> WorkKey { + derive_work_key(contract: contract_owing(required: both_required()), requirements: fixture_requirements()) +} + +fn both_required() -> List { + [build_output(), execution_receipt()] +} + +// A REAL RESOLUTION, NOT A HAND-BUILT ONE. ResolvedOutputObligations is sole_constructor, so these +// fixtures must go through resolve_output_obligations -- which is the point: the gate cannot be fed +// a roster that no resolution produced. +fn resolution_for(required: List, work: WorkKey) -> OutputObligationsResolution { + resolve_output_obligations( + contract: contract_owing(required: required), + requirements: fixture_requirements(), + work_key: work, + judgment: ObligationsJudgedAtJobStart, + ) +} + +fn the_resolution() -> OutputObligationsResolution { + resolution_for(required: both_required(), work: the_work()) +} + +// Two distinct fixture digests. The witness needs a SECOND value so the digest comparison is shown +// to separate rather than answer true for everything -- an equality that never fails would satisfy +// the positive control on its own. +fn digest_a() -> Sha256Digest { + Sha256Digest { hex: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Sha256DigestHex } +} + +fn digest_b() -> Sha256Digest { + Sha256Digest { hex: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as Sha256DigestHex } +} + +fn the_attempt() -> AttemptIdentity { + "attempt-1" as AttemptIdentity +} + +fn other_attempt() -> AttemptIdentity { + "attempt-2" as AttemptIdentity +} + +fn full_membership() -> List { + [ + ManifestMember { obligation: build_output(), content: digest_a() }, + ManifestMember { obligation: execution_receipt(), content: digest_a() }, + ] +} + +fn good_manifest() -> CommitManifest { + CommitManifest { + attempt: the_attempt(), + manifest_digest: digest_a(), + members: full_membership(), + } +} + +fn manifest_with(members: List) -> CommitManifest { + CommitManifest { + attempt: the_attempt(), + manifest_digest: digest_a(), + members: members, + } +} + +// THE READ POPULATION, SUPPLIED SEPARATELY FROM THE WRITE POPULATION. Every fixture below states +// which subjects were actually read back at the origin; a subject absent from this list was never +// read, which is the state the previous shape could not express. +fn obs_of(subject: ObservationSubject, r: OriginReadback) -> OriginObservation { + OriginObservation { subject: subject, readback: r } +} + +fn confirmed(subject: ObservationSubject) -> OriginObservation { + obs_of(subject: subject, r: ReadbackConfirmed { observed_digest: digest_a() }) +} + +fn all_confirmed() -> List { + [confirmed(subject: ObligationSubject { obligation: build_output() }), confirmed(subject: ObligationSubject { obligation: execution_receipt() }), confirmed(subject: CommitManifestSubject {})] +} + +fn reads_with_output(r: OriginReadback) -> List { + [obs_of(subject: ObligationSubject { obligation: build_output() }, r: r), confirmed(subject: ObligationSubject { obligation: execution_receipt() }), confirmed(subject: CommitManifestSubject {})] +} + +fn reads_with_receipt(r: OriginReadback) -> List { + [confirmed(subject: ObligationSubject { obligation: build_output() }), obs_of(subject: ObligationSubject { obligation: execution_receipt() }, r: r), confirmed(subject: CommitManifestSubject {})] +} + +fn reads_with_manifest(r: OriginReadback) -> List { + [confirmed(subject: ObligationSubject { obligation: build_output() }), confirmed(subject: ObligationSubject { obligation: execution_receipt() }), obs_of(subject: CommitManifestSubject {}, r: r)] +} + +fn committed_both() -> List { + [ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ] +} + +fn is_success(p: JobPublication) -> Bool { + match p { + PublishSuccess { attempt: _, manifest_digest: _ } => true + other => false + } +} + +fn is_obligations_unreadable(p: JobPublication) -> Bool { + match p { + PublishObligationsUnreadable { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +fn is_finalization_failure(p: JobPublication) -> Bool { + match p { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +fn is_pending(p: JobPublication) -> Bool { + match p { + PublishPendingReconciliation { attempt: _, subject: _, cause: _, unresolved: _ } => true + other => false + } +} + +fn committed_receipt_only() -> List { + [CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }] +} + +fn committed_build_only() -> List { + [CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }] +} + +fn second_output() -> OutputObligation { + OutputObligation { id: "second-output", kind: OutputArtifactObligation {} } +} + +// A resolution whose work_key does not derive from its contract. resolve_output_obligations refuses +// it with WorkKeyDoesNotMatchContract, which is the only Unreadable cause that exists. +fn unreadable_resolution() -> OutputObligationsResolution { + resolution_for(required: both_required(), work: "not-the-derived-work-key" as WorkKey) +} + +// A VALID resolution for a DIFFERENT work. The seal is intact and the roster is real; only the +// binding is foreign. This is the substitution the seal cannot catch. +fn foreign_work_resolution() -> OutputObligationsResolution { + let foreign_contract = contract_owing(required: [second_output()]) + let foreign_key = derive_work_key(contract: foreign_contract, requirements: fixture_requirements()) + resolve_output_obligations( + contract: foreign_contract, + requirements: fixture_requirements(), + work_key: foreign_key, + judgment: ObligationsJudgedAtJobStart, + ) +} + +fn publish_judging( + computation: ComputationOutcome, + resolution: OutputObligationsResolution, + judged_work: WorkKey, + committed: List, + obs: List, + manifest: CommitManifest +) -> JobPublication { + admit_job_publication( + attempt: the_attempt(), + computation: computation, + resolution: resolution, + judged_work: judged_work, + committed: committed, + observations: obs, + manifest: manifest, + ) +} + +fn publish( + computation: ComputationOutcome, + resolution: OutputObligationsResolution, + committed: List, + obs: List, + manifest: CommitManifest +) -> JobPublication { + publish_judging( + computation: computation, + resolution: resolution, + judged_work: the_work(), + committed: committed, + obs: obs, + manifest: manifest, + ) +} + +// The two rosters below are owed by DIFFERENT contracts, so they derive different work keys. Judging +// them against the_work() is not a test of anything but the work-key wall, so each names its own. +fn empty_contract_work() -> WorkKey { + derive_work_key(contract: contract_owing(required: []), requirements: fixture_requirements()) +} + +fn three_required() -> List { + [build_output(), execution_receipt(), second_output()] +} + +fn three_contract_work() -> WorkKey { + derive_work_key(contract: contract_owing(required: three_required()), requirements: fixture_requirements()) +} + +// THE POSITIVE CONTROL. Without it every refusal below is satisfied by an authority that never +// publishes anything, which is the shape the brief names explicitly: an implementation refusing +// every job does not satisfy M0. +test fn a_complete_attempt_publishes_success() -> Bool { + is_success(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// WITHHOLD THE REQUIRED ARTIFACT. The obligation set must not shrink to match what arrived. +test fn a_withheld_required_artifact_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_receipt_only(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +test fn a_withheld_required_receipt_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_build_only(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// AN UNREADABLE DECLARATION IS NOT AN EMPTY ONE. This is the pair the module's constructor split +// exists for, and it is checked in BOTH directions: the declared-empty roster must still succeed, +// or the refusal above would be satisfied by an authority that simply rejects every zero-length +// roster and the distinction would carry no information. +test fn an_unreadable_artifact_declaration_prevents_success() -> Bool { + is_obligations_unreadable(p: publish( + computation: ComputationSucceeded, + resolution: unreadable_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +test fn a_declared_empty_artifact_roster_still_succeeds() -> Bool { + is_success(p: publish_judging( + computation: ComputationSucceeded, + resolution: resolution_for(required: [], work: empty_contract_work()), + judged_work: empty_contract_work(), + committed: [], + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +test fn an_unreadable_receipt_declaration_prevents_success() -> Bool { + is_obligations_unreadable(p: publish( + computation: ComputationSucceeded, + resolution: unreadable_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// A SUCCESSFUL STORAGE FINALIZATION MUST NOT INVERT A FAILED BUILD. +test fn a_failed_build_stays_failed_though_every_receipt_commits() -> Bool { + match publish( + computation: ComputationFailed { cause: "compile error" }, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + ) { + PublishComputationFailed { attempt: _, cause: c } => c == "compile error" + other => false + } +} + +// A READBACK THAT DISAGREES WITH THE COMMITTED RECORD IS NOT A HIT. +test fn a_readback_digest_mismatch_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat([ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + ], committed_receipt_only()), + obs: reads_with_output(r: ReadbackConfirmed { observed_digest: digest_b() }), + manifest: good_manifest(), + )) +} + +test fn an_absent_object_at_origin_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat([ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + ], committed_receipt_only()), + obs: reads_with_output(r: ReadbackAbsent), + manifest: good_manifest(), + )) +} + +// A LOST ACKNOWLEDGEMENT IS NEITHER SUCCESS NOR FAILURE. This is the arm that would disappear if +// unknown were folded into either decided outcome, so it is asserted as pending specifically -- +// not merely as not-success, which ReadbackAbsent already satisfies. +test fn a_lost_acknowledgement_is_pending_not_decided() -> Bool { + is_pending(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat([ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + ], committed_receipt_only()), + obs: reads_with_output(r: ReadbackUnknown { cause: "upload acknowledgement lost" }), + manifest: good_manifest(), + )) +} + +// A DEFINITELY-MISSING OBJECT DOMINATES AN UNKNOWN ONE, so a job that is already incomplete is not +// parked in pending forever waiting on a reconcile that cannot complete it. +test fn a_missing_object_dominates_an_unknown_one() -> Bool { + is_finalization_failure(p: publish_judging( + computation: ComputationSucceeded, + resolution: resolution_for(required: three_required(), work: three_contract_work()), + judged_work: three_contract_work(), + committed: committed_both(), + obs: reads_with_output(r: ReadbackUnknown { cause: "upload acknowledgement lost" }), + manifest: good_manifest(), + )) +} + +// EVIDENCE FROM A FOREIGN ATTEMPT DOES NOT DISCHARGE THIS ATTEMPT'S OBLIGATIONS. +test fn a_foreign_attempts_artifact_does_not_satisfy_this_one() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat([ + CommittedOutput { obligation: build_output(), attempt: other_attempt(), recorded_digest: digest_a() }, + ], committed_receipt_only()), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +test fn a_manifest_bound_to_another_attempt_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: CommitManifest { + attempt: other_attempt(), + manifest_digest: digest_a(), + members: full_membership(), + }, + )) +} + +test fn an_uncommitted_manifest_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: reads_with_manifest(r: ReadbackAbsent), + manifest: CommitManifest { + attempt: the_attempt(), + manifest_digest: digest_a(), + members: full_membership(), + }, + )) +} + +// THE AGGREGATE MUST NOT ERASE THE EVIDENCE UNDERNEATH IT. One output absent and one receipt +// undetermined is a failed job WITH work left to reconcile, and a verdict that reported only the +// absent one left the reconciler nothing to act on. +test fn a_dominating_failure_still_reports_the_unresolved_receipt() -> Bool { + match publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: [ + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ], + obs: reads_with_receipt(r: ReadbackUnknown { cause: "receipt upload acknowledgement lost" }), + manifest: good_manifest(), + ) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: u } => u.count() == 2 + other => false + } +} + +// AND THE WORK LIST IS THE UNRESOLVED OBLIGATIONS, NOT EVERY OBLIGATION. One missing artifact +// beside a committed receipt and a committed manifest must report exactly one item; a field that +// simply echoed the whole roster would report three and satisfy the test above just as well. +test fn the_work_list_carries_only_the_unresolved_obligations() -> Bool { + match publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_receipt_only(), + obs: all_confirmed(), + manifest: good_manifest(), + ) { + PublishFinalizationFailed { attempt: _, subject: _, cause: _, unresolved: u } => u.count() == 1 + other => false + } +} + +// A RECEIPT'S CONTENT IS CHECKED, NOT MERELY ITS PRESENCE. The receipt branch used to discard the +// observed digest, so any confirmed readback satisfied it -- the artifact path's exact check, absent +// one carrier over. +test fn a_receipt_content_mismatch_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat(committed_build_only(), [ + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ]), + obs: reads_with_receipt(r: ReadbackConfirmed { observed_digest: digest_b() }), + manifest: good_manifest(), + )) +} + +// THE MANIFEST MUST NAME THE POPULATION. Every object check passing does not establish that the +// manifest names those objects, and reconstruction after the worker is gone needs the relation. +test fn a_manifest_omitting_a_required_object_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: manifest_with(members: [ManifestMember { obligation: execution_receipt(), content: digest_a() }]), + )) +} + +test fn a_manifest_naming_the_wrong_content_prevents_success() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: manifest_with(members: [ + ManifestMember { obligation: build_output(), content: digest_b() }, + ManifestMember { obligation: execution_receipt(), content: digest_a() }, + ]), + )) +} + +// TWO ROWS CLAIMING ONE IDENTITY REFUSE RATHER THAN LETTING POSITION DECIDE. +test fn conflicting_rows_for_one_identity_refuse() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: concat([ + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_a() }, + CommittedOutput { obligation: build_output(), attempt: the_attempt(), recorded_digest: digest_b() }, + ], committed_receipt_only()), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// A FAILED BUILD KEEPS ITS CAUSE AND STILL REPORTS THE RECONCILIATION WORK. +test fn a_failed_build_still_reports_an_unresolved_receipt() -> Bool { + match publish( + computation: ComputationFailed { cause: "compile error" }, + resolution: the_resolution(), + committed: concat(committed_build_only(), [ + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ]), + obs: reads_with_receipt(r: ReadbackUnknown { cause: "receipt upload acknowledgement lost" }), + manifest: good_manifest(), + ) { + PublishComputationFailed { attempt: _, cause: c, unresolved: u } => (c == "compile error") && (u.count() == 1) + other => false + } +} + +// AND A FAILED BUILD WHOSE STORAGE IS WHOLLY RESOLVED CARRIES AN EMPTY WORK LIST, so the field above +// is not satisfied by always reporting something. +test fn a_failed_build_with_resolved_storage_reports_no_work() -> Bool { + match publish( + computation: ComputationFailed { cause: "compile error" }, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + ) { + PublishComputationFailed { attempt: _, cause: c, unresolved: u } => (c == "compile error") && (u.count() == 0) + other => false + } +} + +// AN UNENUMERABLE ROSTER DOES NOT STOP THE MANIFEST BEING ASSESSED. The earlier form of this +// witness said "the other roster", which the one-roster collapse retired -- there is no sibling +// roster to assess any more. The property that survives is the one that mattered: "cannot enumerate +// this population" is not "stop examining every other population", so an undetermined manifest must +// still reach the work list beside an unenumerable roster. Asserting a count of 2 is what +// distinguishes that from an early return. +test fn an_unenumerable_roster_still_assesses_the_manifest() -> Bool { + match publish( + computation: ComputationSucceeded, + resolution: unreadable_resolution(), + committed: committed_both(), + obs: reads_with_manifest(r: ReadbackUnknown { cause: "manifest upload acknowledgement lost" }), + manifest: good_manifest(), + ) { + PublishObligationsUnreadable { attempt: _, subject: _, cause: _, unresolved: u } => u.count() == 2 + other => false + } +} + +// THE READ MUST HAVE HAPPENED. These three are the discriminating reds for splitting the read +// population out of the committed rows: while `readback` was a FIELD of each committed row, an +// object that nobody read had no representation, so "every required object was confirmed at the +// origin" and "every required object carries a confirmation somebody wrote down" were the same +// sentence. Success must now be reachable only when a read is RECORDED for each subject, and an +// unperformed read reconciles rather than fails, because it says nothing about whether the object +// is there. +test fn a_required_object_never_read_back_cannot_publish_success() -> Bool { + is_pending(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: [confirmed(subject: ObligationSubject { obligation: execution_receipt() }), confirmed(subject: CommitManifestSubject {})], + manifest: good_manifest(), + )) +} + +// THE MANIFEST IS SUBJECT TO THE SAME RULE, and separately, because it is the root a result is +// reconstructed from: every required object confirmed beneath an unread manifest is a set of +// objects nobody can prove belong to one another. +test fn an_unread_manifest_cannot_publish_success() -> Bool { + is_pending(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: [confirmed(subject: ObligationSubject { obligation: build_output() }), confirmed(subject: ObligationSubject { obligation: execution_receipt() })], + manifest: good_manifest(), + )) +} + +// TWO READS CLAIMING ONE SUBJECT REFUSE rather than letting position pick the agreeable one -- the +// same law the committed rows already obey, applied to the population that now carries the reads. +test fn two_reads_claiming_one_subject_refuse() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: [ + confirmed(subject: ObligationSubject { obligation: build_output() }), + obs_of(subject: ObligationSubject { obligation: build_output() }, r: ReadbackAbsent), + confirmed(subject: ObligationSubject { obligation: execution_receipt() }), + confirmed(subject: CommitManifestSubject {}), + ], + manifest: good_manifest(), + )) +} + +// THE SUBSTITUTION THE SEAL CANNOT CATCH. sole_constructor makes a forged resolution unwritable, +// and resolve_output_obligations refuses a work_key that does not derive from its own contract. Both +// hold here: this resolution is genuine, internally consistent, and sealed. It is simply about a +// DIFFERENT work than the one the gate was asked to judge. Nothing upstream can detect that, because +// only the gate knows which work it was asked about -- so if this publishes anything but a refusal, +// a caller can discharge job A's obligations with job B's roster. +test fn a_resolution_for_another_work_cannot_publish_success() -> Bool { + is_obligations_unreadable(p: publish( + computation: ComputationSucceeded, + resolution: foreign_work_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// AND THE POSITIVE CONTROL FOR IT: the same shape with the CORRECT work still succeeds, so the +// refusal above is not satisfied by a gate that rejects every resolution. +test fn a_resolution_for_the_judged_work_publishes_success() -> Bool { + is_success(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: committed_both(), + obs: all_confirmed(), + manifest: good_manifest(), + )) +} + +// KIND IS PART OF THE KEY. The committed row carries the right id under the WRONG kind, so a join +// on id alone would discharge the artifact obligation with a terminal-result row. +test fn the_same_id_under_another_kind_does_not_discharge_it() -> Bool { + is_finalization_failure(p: publish( + computation: ComputationSucceeded, + resolution: the_resolution(), + committed: [ + CommittedOutput { obligation: build_output_as_terminal(), attempt: the_attempt(), recorded_digest: digest_a() }, + CommittedOutput { obligation: execution_receipt(), attempt: the_attempt(), recorded_digest: digest_a() }, + ], + obs: all_confirmed(), + manifest: good_manifest(), + )) +}