diff --git a/dag/gunbc/spark/pair_serving_d0.dag b/dag/gunbc/spark/pair_serving_d0.dag index 6076a95b16c..693068ac8d7 100644 --- a/dag/gunbc/spark/pair_serving_d0.dag +++ b/dag/gunbc/spark/pair_serving_d0.dag @@ -11,7 +11,8 @@ import std.content_hash { ContentHash, content_hash_of_value, serialize_content_ import std.temporal_effect { HeldLease, LeaseEpoch, held_lease, LeaseRunningExpected } import std.scoped_authorization { OperatorGrant, ScopedAuthorization, AuthorizationGranted, AuthorizationRequest, AuthorizationScope, AttemptIdentity, AuthorizedAction, - AuthorizationPermitted, AuthorizationRefused, AuthorizationRefusalCause, AuthorizationEscalationMismatch, authorize, authorization_scope_render, authorization_refusal_reason, + AuthorizationPermitted, AuthorizationRefused, AuthorizationRefusalCause, AuthorizationEscalationMismatch, authorize, authorization_scopes_render, authorization_refusal_reason, + intent_frame_head, parse_intent_scopes, authorization_scope_eq, AuthorizationClaimState, Unclaimed, ClaimedBy, CompletedBy, AbortedBy, claim_authorization, claim_transition_claimed, claim_transition_completed, claim_transition_aborted, AuthorizationClaimOutcome, ClaimHeld, ClaimLost, ClaimUndecided, authorization_claim_outcome, authorization_claim_key, parse_authorization_claim_state, } @@ -228,12 +229,24 @@ fn d0_subject_wire(subject: D0Subject) -> String { // collision class there is the same defect the pending-state binding was moved off it for. // The digest is taken through the sha256sum realization, so it is an effect and can be // unavailable -- which is a refusal, never a substituted structural hash. -data d0_intent_schema: String = "pair-serving-d0-intent/v1" +data d0_intent_schema: String = "pair-serving-d0-intent/v2" + +// THE HEAD IS THE std.scoped_authorization INTENT FRAME: the schema line and the scope written by +// the one scope codec, so a verifier holding this text and the approved revision reads the approved +// scope with parse_intent_scopes and no knowledge of D0 (the fabric store door does exactly that). +// A scope the codec cannot write has no frame, and then there is NO intent: the refusal is here, at +// the filer, before anything is hashed or filed -- never an empty head standing in for one (review +// 72009). d0_required_scope has a wire, so the refusing arm is unreachable today; it stays typed. +fn d0_intent_text(subject: D0Subject, group: FabricGroup, transaction: NonEmptyStr) -> String? { + match intent_frame_head(schema: d0_intent_schema as NonEmptyStr, scopes: [d0_required_scope()]) { + Absent => none + Present { value: head } => Present { value: d0_intent_body(head: head, subject: subject, group: group, transaction: transaction) } + } +} -fn d0_intent_text(subject: D0Subject, group: FabricGroup, transaction: NonEmptyStr) -> String { +fn d0_intent_body(head: String, subject: D0Subject, group: FabricGroup, transaction: NonEmptyStr) -> String { join([ - d0_intent_schema, - "\nscope=", authorization_scope_render(scope: d0_required_scope()), + head, "\ntarget=", fabric_group_wire(g: group) as String, "\nattempt=", transaction as String, "\n", d0_subject_wire(subject: subject), @@ -242,7 +255,10 @@ fn d0_intent_text(subject: D0Subject, group: FabricGroup, transaction: NonEmptyS } fn d0_intent_hash(subject: D0Subject, group: FabricGroup, transaction: NonEmptyStr) -> ContentHash? { - sha256_of_text(text: d0_intent_text(subject: subject, group: group, transaction: transaction)) + match d0_intent_text(subject: subject, group: group, transaction: transaction) { + Absent => none + Present { value: text } => sha256_of_text(text: text) + } } fn sha256_of_text(text: String) -> ContentHash? { @@ -252,6 +268,18 @@ fn sha256_of_text(text: String) -> ContentHash? { } } +// WHAT THE OPERATOR READS IS RENDERED FROM WHAT THE STORE CHECKS. The approval notification shows +// the filed purpose, not the intent text, and a constant sentence here let the operator approve an +// operation whose scope and subject they never saw. The purpose is therefore rendered from the same +// typed scope the intent frame carries and the same subject wire the intent text carries. +fn d0_purpose(subject: D0Subject, transaction: NonEmptyStr) -> NonEmptyStr { + join([ + "Cut D D0 (transaction ", transaction as String, "): suspend the pair-serving authority for a bounded successor. Scope: ", + authorization_scopes_render(scopes: [d0_required_scope()]), + ". Over: ", d0_subject_wire(subject: subject), ".", + ], "") as NonEmptyStr +} + // The request D0 makes of a grant: the escalation the caller names, the required scope, the // grant's own subject (what is being checked is that the grant agrees with itself and with this // run), the transaction as the attempt, and the intent over the exact operation. Destructive. @@ -260,7 +288,7 @@ fn d0_request(escalation_id: NonEmptyStr, subject: D0Subject, transaction: NonEm escalation_id: escalation_id, scopes: [d0_required_scope()], subject: subject, - purpose: "suspend the pair-serving authority for a bounded successor transaction (Cut D D0)" as NonEmptyStr, + purpose: d0_purpose(subject: subject, transaction: transaction), attempt: (transaction as String) as AttemptIdentity, intent_hash: intent, destructive: true, @@ -334,8 +362,11 @@ fn line_at(lines: List, i: Int) -> String { fn parse_d0_intent_text(text: String) -> D0IntentParsed? { let lines = intent_lines(text: text) - if line_at(lines: lines, i: 0) != d0_intent_schema { none } - else if line_at(lines: lines, i: 1) != join(["scope=", authorization_scope_render(scope: d0_required_scope())], "") { none } + let framed = match parse_intent_scopes(text: text) { + Present { value: f } => (f.schema as String) == d0_intent_schema && count(f.scopes) == 1 && all(f.scopes, sc => authorization_scope_eq(a: sc, b: d0_required_scope())) + Absent => false + } + if !framed { none } else { match intent_field(line: line_at(lines: lines, i: 2), key: "target") { Absent => none @@ -376,7 +407,7 @@ fn parse_d0_intent_text(text: String) -> D0IntentParsed? { Absent => none Present { value: hw } => { let subject = D0Subject { group: subject_group, hosts: parse_d0_hosts(wire: hw), successor: successor, cleanup: cleanup, term: term } - if d0_intent_text(subject: subject, group: group, transaction: attempt as NonEmptyStr) == text { + if (match d0_intent_text(subject: subject, group: group, transaction: attempt as NonEmptyStr) { Present { value: t } => t == text Absent => false }) { Present { value: D0IntentParsed { group: group, transaction: attempt as NonEmptyStr, subject: subject } } } else { none } } @@ -1448,7 +1479,7 @@ fn d0_admission_text(a: D0GrantAdmission, group: String) -> String { D0GrantAdmitted { admitted: _ } => "admitted" D0GrantForAnotherGroup { granted: og } => join(["the grant authorizes ", fabric_group_wire(g: og) as String, ", not ", group], "") D0GrantForAnotherPopulation { granted: gr, current: cu } => join(["the grant authorizes hosts [", hosts_wire(hs: gr), "] but ", group, " is currently [", hosts_wire(hs: cu), "]; the operator did not see this population"], "") - D0GrantIntentUnavailable => "the intent over the exact operation could not be digested (sha256sum unavailable), so the grant cannot be compared to it" + D0GrantIntentUnavailable => "the intent over the exact operation could not be stated and digested (sha256sum unavailable, or the scope has no wire), so the grant cannot be compared to it" D0GrantRefused { cause: c } => join(["the authorization does not permit this operation: ", authorization_refusal_reason(cause: c)], "") D0GrantClaimLost { holder: h } => join(["the grant is held by another attempt: ", h as String], "") D0GrantClaimUndecided { reason: r } => join(["the grant's claim could not be decided: ", r as String], "") diff --git a/dag/gunbc/spark/pair_serving_d0_authorization.dag b/dag/gunbc/spark/pair_serving_d0_authorization.dag index e8b9e10aad8..86484de9e4a 100644 --- a/dag/gunbc/spark/pair_serving_d0_authorization.dag +++ b/dag/gunbc/spark/pair_serving_d0_authorization.dag @@ -71,7 +71,7 @@ fn d0_authorization_request(group: FabricGroup, escalation_id: NonEmptyStr, tran D0SubjectRefused { defects: d } => D0RequestRefused { defects: d } D0SubjectFor { subject: s } => match d0_intent_hash(subject: s, group: group, transaction: transaction) { - Absent => D0RequestUnestablished { obligations: ["the intent over the exact operation could not be digested (sha256sum unavailable)" as NonEmptyStr] } + Absent => D0RequestUnestablished { obligations: ["the intent over the exact operation could not be stated and digested (sha256sum unavailable, or the scope has no wire)" as NonEmptyStr] } Present { value: i } => D0RequestFor { request: d0_request(escalation_id: escalation_id, subject: s, transaction: transaction, intent: i) } } } @@ -101,7 +101,7 @@ fn d0_filed_request(text: String, group: FabricGroup, escalation_id: NonEmptyStr D0FiledRequestRefused { reason: join(["the frozen filing under this escalation is transaction ", parsed.transaction as String, ", not ", transaction as String], "") as NonEmptyStr } } else { match d0_intent_hash(subject: parsed.subject, group: group, transaction: transaction) { - Absent => D0FiledRequestRefused { reason: "the frozen intent could not be digested (sha256sum unavailable)" as NonEmptyStr } + Absent => D0FiledRequestRefused { reason: "the frozen intent could not be stated and digested (sha256sum unavailable, or the scope has no wire)" as NonEmptyStr } Present { value: i } => D0FiledRequestFor { request: d0_request(escalation_id: escalation_id, subject: parsed.subject, transaction: transaction, intent: i), intent_text: frozen.intent_text } } } @@ -111,7 +111,7 @@ fn d0_filed_request(text: String, group: FabricGroup, escalation_id: NonEmptyStr } // The intent text a live request is frozen as: the same bytes its intent hash was taken over. -fn d0_request_intent_text(request: AuthorizationRequest, group: FabricGroup, transaction: NonEmptyStr) -> String { +fn d0_request_intent_text(request: AuthorizationRequest, group: FabricGroup, transaction: NonEmptyStr) -> String? { d0_intent_text(subject: request.subject, group: group, transaction: transaction) } diff --git a/dag/gunbc/spark/pair_serving_d0_door.dag b/dag/gunbc/spark/pair_serving_d0_door.dag index 532723beadc..dd98f52f4ac 100644 --- a/dag/gunbc/spark/pair_serving_d0_door.dag +++ b/dag/gunbc/spark/pair_serving_d0_door.dag @@ -120,7 +120,10 @@ fn d0_run_kind(store: FabricStorageBinding, g: FabricGroup, escalation_id: NonEm D0RequestUnestablished { obligations: o } => D0RunRefused { reason: join(["the consent has no subject to be asked over yet; still to be established: ", join(map(o, x => x as String), "; ")], "") } D0RequestRefused { defects: d } => D0RunRefused { reason: join(["the consent has no subject to be asked over: the candidate's source is refused and must be corrected, not read further: ", join(map(d, x => x as String), "; ")], "") } D0RequestFor { request: request } => - match d0_freeze_filing(store: store, escalation_id: escalation_id, intent_text: d0_request_intent_text(request: request, group: g, transaction: transaction), execution_revision: execution_revision) { + match d0_request_intent_text(request: request, group: g, transaction: transaction) { + Absent => D0RunRefused { reason: "the consent's scope has no wire, so no intent can be stated over it (std.scoped_authorization intent_frame_head refused)" } + Present { value: intent_text } => + match d0_freeze_filing(store: store, escalation_id: escalation_id, intent_text: intent_text, execution_revision: execution_revision) { D0FilingFreezeRefused { reason: r } => D0RunRefused { reason: r as String } D0FilingFrozen => D0FirstRun { request: request } D0FilingAlreadyHeld { text: t } => @@ -129,6 +132,7 @@ fn d0_run_kind(store: FabricStorageBinding, g: FabricGroup, escalation_id: NonEm D0FiledRequestFor { request: filed, intent_text: _ } => D0Rerun { request: filed } } } + } } } } diff --git a/dag/std/effect_grant.dag b/dag/std/effect_grant.dag index 7804048278b..9143d8c8315 100644 --- a/dag/std/effect_grant.dag +++ b/dag/std/effect_grant.dag @@ -1,6 +1,7 @@ module std.effect_grant import std.types { Bool, String, List } +import v2.std.algebra { length, filter } import std.effects { EffectShape, ReadEffect, UpsertEffect, DeleteEffect, CreateEffect, AppendEffect, ExecuteEffect } import std.materialization_ladder { Frame } import std.access { @@ -239,11 +240,35 @@ fn namespace_tree_label(tree: NamespaceTree) -> String { FilesystemPathTree => "filesystem" UriTree => "uri" ProcTree => "proc" - ServiceOpTree { service: svc } => concat("service:", svc) + ServiceOpTree { service: svc } => concat(service_tree_label_prefix, svc) CodeNameTree => "code-name" } } +// THE LABELS' INVERSES, DERIVED FROM THE LABELS (review 72135): each inverse searches the closed +// value set through the forward label function, so a label is spelled once, where it is written, and +// a reader cannot drift from the writer. A service tree naming no service is refused rather than read +// as some default service; its prefix is the one the writer uses. +data effect_verb_values: List = [Read, Write, Execute] + +fn verb_of_label(label: String) -> Verb? { + (filter(effect_verb_values, v => effect_verb_label(verb: v) == label)).first() +} + +data service_tree_label_prefix: String = "service:" + +data namespace_trees_without_parameters: List = [FilesystemPathTree, UriTree, ProcTree, CodeNameTree] + +fn namespace_tree_of_label(label: String) -> NamespaceTree? { + match (filter(namespace_trees_without_parameters, t => namespace_tree_label(tree: t) == label)).first() { + Present { value: t } => Present { value: t } + Absent => + if starts_with(s: label, prefix: service_tree_label_prefix) && length(label) > length(service_tree_label_prefix) { + Present { value: ServiceOpTree { service: substring(s: label, start: length(service_tree_label_prefix), end: length(label)) } } + } else { none } + } +} + fn namespace_position_label(position: NamespacePosition) -> String { concat( namespace_tree_label(tree: position.tree), diff --git a/dag/std/scoped_authorization.dag b/dag/std/scoped_authorization.dag index e8bc110bd50..9f21b9801e1 100644 --- a/dag/std/scoped_authorization.dag +++ b/dag/std/scoped_authorization.dag @@ -1,6 +1,7 @@ module std.scoped_authorization -import std.types { NonEmptyStr, String, Bool, Timestamp, List } +import std.types { NonEmptyStr, String, Bool, Timestamp, List, Int } +import v2.std.algebra { skip, is_empty, length } import v2.std.optional { Present, Absent } import std.content_hash { ContentHash, @@ -19,6 +20,11 @@ import std.effect_grant { position_under, effect_verb_label, namespace_position_label, + namespace_tree_label, + namespace_tree_eq, + path_is_prefix, + verb_of_label, + namespace_tree_of_label, } import std.durable_compare_and_set { CasAttempt, @@ -577,6 +583,147 @@ fn claim_holder_payload(readable: CasReadableSlot) -> NonEmptyStr { } } +// ── THE SCOPE ON THE WIRE, AND THE INTENT THAT STATES IT ────────────────────────────────────── +// +// ONE CODEC, WRITTEN BY THE FILER AND READ BY THE VERIFIER. An intent_hash binds the exact text of +// an operation, so when that text states the scope, a verifier that holds the text and the approved +// revision learns the approved scope without trusting anyone to repeat it. Before this codec the +// only spelling of a scope was authorization_scope_render -- a sentence for a person -- and a +// verifier could only compare sentences, so filer and verifier agreed exactly as long as nobody +// edited one side's phrasing. The wire is four space-separated fields: verb label, tree label, the +// path joined by "/", the action. A scope whose fields cannot be written unambiguously (a space in +// any field, a "/" or an empty segment in the path, an empty path) has NO wire and is refused at +// encode; decode reads back only what encode writes (the round trip is the check). +data scope_wire_field_separator: String = " " +data scope_wire_path_separator: String = "/" + +fn scope_wire_field_admissible(field: String) -> Bool { + field != "" && !string_contains(s: field, pattern: scope_wire_field_separator) +} + +fn scope_wire_segment_admissible(segment: String) -> Bool { + scope_wire_field_admissible(field: segment) && !string_contains(s: segment, pattern: scope_wire_path_separator) +} + +fn authorization_scope_wire(scope: AuthorizationScope) -> NonEmptyStr? { + let tree = namespace_tree_label(tree: scope.resource.tree) + if !scope_wire_field_admissible(field: tree) + || is_empty(scope.resource.path) + || !all(scope.resource.path, seg => scope_wire_segment_admissible(segment: seg)) + || !scope_wire_field_admissible(field: scope.action as String) { + none + } else { + Present { value: join([ + effect_verb_label(verb: scope.verb), + tree, + join(scope.resource.path, scope_wire_path_separator), + scope.action as String, + ], scope_wire_field_separator) as NonEmptyStr } + } +} + +fn parse_authorization_scope_wire(wire: String) -> AuthorizationScope? { + let fields = split(s: wire, delimiter: scope_wire_field_separator) + if count(fields) != 4 { none } else { + match verb_of_label(label: field_at(fields: fields, i: 0)) { + Absent => none + Present { value: verb } => + match namespace_tree_of_label(label: field_at(fields: fields, i: 1)) { + Absent => none + Present { value: tree } => { + let action = field_at(fields: fields, i: 3) + if action == "" { none } else { + let scope = AuthorizationScope { + verb: verb, + resource: NamespacePosition { tree: tree, path: split(s: field_at(fields: fields, i: 2), delimiter: scope_wire_path_separator) }, + action: action as AuthorizedAction, + } + match authorization_scope_wire(scope: scope) { + Present { value: w } => if (w as String) == wire { Present { value: scope } } else { none } + Absent => none + } + } + } + } + } + } +} + +// EXACT scope identity -- same verb, same tree, same path, same action -- for a verifier that must +// know the scope stated IS the one it requires, not merely one that covers it (scope_covers is the +// reach relation a grant is judged by; an intent names its operation exactly). +fn authorization_scope_eq(a: AuthorizationScope, b: AuthorizationScope) -> Bool { + verb_eq(a: a.verb, b: b.verb) + && namespace_tree_eq(a: a.resource.tree, b: b.resource.tree) + && path_is_prefix(prefix: a.resource.path, path: b.resource.path) + && path_is_prefix(prefix: b.resource.path, path: a.resource.path) + && a.action == b.action +} + +fn field_at(fields: List, i: Int) -> String { + match first(fields |> skip(n: i)) { Present { value: f } => f Absent => "" } +} + +// THE INTENT FRAME. Every operation text an intent_hash is taken over that must be verifiable by a +// party other than its filer opens with ONE schema line and then its scopes, one `scope=` line +// each, and at least one: a verifier reads the scopes by position through parse_intent_scopes and +// needs no knowledge of the domain below them. What follows the scopes is the domain's own and is +// not read here. An intent with no scope line, a scope line that does not decode, or a scope with +// no wire has no frame -- refused, never read as "no scopes required", which would make an intent +// that stated nothing the most-authorized one (the empty-set trap scope_covered_by_any names). +data intent_scope_line_prefix: String = "scope=" + +fn intent_frame_head(schema: NonEmptyStr, scopes: List) -> String? { + if is_empty(scopes) { none } else { + fold(scopes, init: Present { value: schema as String }, f: (acc, sc) => + match acc { + Absent => none + Present { value: text } => + match authorization_scope_wire(scope: sc) { + Absent => none + Present { value: w } => Present { value: join([text, "\n", intent_scope_line_prefix, w as String], "") } + } + }) + } +} + +type IntentScopes { + schema: NonEmptyStr + scopes: List +} + +fn parse_intent_scopes(text: String) -> IntentScopes? { + let lines = split(s: text, delimiter: "\n") + let schema = field_at(fields: lines, i: 0) + if schema == "" { none } else { + let start: IntentScopeScan = ScanningScopes { scopes: [] as List } + let scan = fold(lines |> skip(n: 1), init: start, f: (acc, l) => + match acc { + ScopesEnded { scopes: _ } => acc + ScopeLineRefused => acc + ScanningScopes { scopes: xs } => + if starts_with(s: l, prefix: intent_scope_line_prefix) { + match parse_authorization_scope_wire(wire: substring(s: l, start: length(intent_scope_line_prefix), end: length(l))) { + Present { value: sc } => ScanningScopes { scopes: concat(xs, [sc]) } + Absent => ScopeLineRefused + } + } else { ScopesEnded { scopes: xs } } + }) + match scan { + ScopeLineRefused => none + ScanningScopes { scopes: xs } => if is_empty(xs) { none } else { Present { value: IntentScopes { schema: schema as NonEmptyStr, scopes: xs } } } + ScopesEnded { scopes: xs } => if is_empty(xs) { none } else { Present { value: IntentScopes { schema: schema as NonEmptyStr, scopes: xs } } } + } + } +} + +// The scan's three states, as the closed sum they are (review 72048): still reading scope lines, +// past the last one, or stopped at a line that claims to be a scope and does not decode. +type IntentScopeScan + = ScanningScopes { scopes: List } + | ScopesEnded { scopes: List } + | ScopeLineRefused + // EVERY FIELD THE SCOPE COMPARISON READS IS RENDERED, and that is the whole rule. scope_covers // judges verb, resource and action, so a render omitting any of the three can print two identical // strings for a mismatch it just refused -- a diagnostic that cannot distinguish the two sides of diff --git a/dag/test/claim/scoped_authorization_scope_wire_witness_test.dag b/dag/test/claim/scoped_authorization_scope_wire_witness_test.dag new file mode 100644 index 00000000000..6eb5fa6633e --- /dev/null +++ b/dag/test/claim/scoped_authorization_scope_wire_witness_test.dag @@ -0,0 +1,89 @@ +module test.claim.scoped_authorization_scope_wire + +import std.logic { Bool } +import std.types { String, NonEmptyStr, List } +import v2.std.optional { Present, Absent } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } +import std.effect_grant { Read, Write, Execute, NamespacePosition, CodeNameTree, FilesystemPathTree, ServiceOpTree } +import std.scoped_authorization { + AuthorizationScope, AuthorizedAction, + authorization_scope_wire, parse_authorization_scope_wire, authorization_scope_eq, + intent_frame_head, parse_intent_scopes, +} + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +// ONE CODEC, BOTH DIRECTIONS. A scope the filer writes reads back as exactly that scope, in every +// tree the label vocabulary names; bytes the encoder never writes read as nothing; and a scope whose +// fields cannot be written without ambiguity has no wire at all, so no verifier can be handed one. + +fn sw_scope(verb_write: Bool, path: List, action: String) -> AuthorizationScope { + AuthorizationScope { + verb: if verb_write { Write } else { Read }, + resource: NamespacePosition { tree: CodeNameTree, path: path }, + action: (action as NonEmptyStr) as AuthorizedAction, + } +} + +fn sw_round_trips(scope: AuthorizationScope) -> Bool { + match authorization_scope_wire(scope: scope) { + Absent => false + Present { value: w } => + match parse_authorization_scope_wire(wire: w as String) { + Absent => false + Present { value: back } => authorization_scope_eq(a: back, b: scope) + } + } +} + +test fn every_writable_scope_round_trips_through_its_wire() -> Bool { + sw_round_trips(scope: sw_scope(verb_write: true, path: ["gunbc", "spark", "pair_serving_authority"], action: "suspend-pair-serving-authority")) + && sw_round_trips(scope: sw_scope(verb_write: false, path: ["one"], action: "read-it")) + && sw_round_trips(scope: AuthorizationScope { verb: Execute, resource: NamespacePosition { tree: FilesystemPathTree, path: ["usr", "bin", "tool"] }, action: ("run" as NonEmptyStr) as AuthorizedAction }) + && sw_round_trips(scope: AuthorizationScope { verb: Write, resource: NamespacePosition { tree: ServiceOpTree { service: "redfish" }, path: ["Systems", "1"] }, action: ("reset" as NonEmptyStr) as AuthorizedAction }) + && (match authorization_scope_wire(scope: sw_scope(verb_write: true, path: ["gunbc", "spark", "pair_serving_authority"], action: "suspend-pair-serving-authority")) { + Present { value: w } => (w as String) == "write code-name gunbc/spark/pair_serving_authority suspend-pair-serving-authority" + Absent => false + }) +} + +test fn a_scope_with_no_unambiguous_wire_has_none() -> Bool { + (match authorization_scope_wire(scope: sw_scope(verb_write: true, path: ["a b"], action: "x")) { Absent => true Present { value: _ } => false }) + && (match authorization_scope_wire(scope: sw_scope(verb_write: true, path: ["a/b"], action: "x")) { Absent => true Present { value: _ } => false }) + && (match authorization_scope_wire(scope: sw_scope(verb_write: true, path: [] as List, action: "x")) { Absent => true Present { value: _ } => false }) + && (match authorization_scope_wire(scope: sw_scope(verb_write: true, path: ["a"], action: "two words")) { Absent => true Present { value: _ } => false }) +} + +test fn bytes_the_encoder_never_writes_decode_to_nothing() -> Bool { + (match parse_authorization_scope_wire(wire: "write code-name gunbc/spark") { Absent => true Present { value: _ } => false }) + && (match parse_authorization_scope_wire(wire: "delete code-name gunbc x") { Absent => true Present { value: _ } => false }) + && (match parse_authorization_scope_wire(wire: "write nowhere gunbc x") { Absent => true Present { value: _ } => false }) + && (match parse_authorization_scope_wire(wire: "write code-name gunbc//spark x") { Absent => true Present { value: _ } => false }) + && (match parse_authorization_scope_wire(wire: "write code-name gunbc x") { Absent => true Present { value: _ } => false }) +} + +// THE FRAME: a verifier reads the scopes by position with no knowledge of the domain below them. +test fn an_intent_frame_reads_back_its_schema_and_scopes() -> Bool { + let a = sw_scope(verb_write: true, path: ["gunbc", "spark", "pair_serving_authority"], action: "suspend-pair-serving-authority") + let b = sw_scope(verb_write: false, path: ["gunbc", "fabric"], action: "read-head") + match intent_frame_head(schema: "some-intent/v1" as NonEmptyStr, scopes: [a, b]) { + Absent => false + Present { value: head } => + match parse_intent_scopes(text: head + "\ntarget=group-a\nattempt=tx\n") { + Absent => false + Present { value: f } => + (f.schema as String) == "some-intent/v1" + && count(f.scopes) == 2 + && authorization_scope_eq(a: (match first(f.scopes) { Present { value: x } => x Absent => b }), b: a) + } + } +} + +// THE RED: a text that states no scope, or a scope line that does not decode, has no frame -- it is +// refused, never read as an intent requiring nothing. +test fn an_intent_without_a_decodable_scope_has_no_frame() -> Bool { + (match parse_intent_scopes(text: "some-intent/v1\ntarget=group-a\n") { Absent => true Present { value: _ } => false }) + && (match parse_intent_scopes(text: "some-intent/v1\nscope=write code-name gunbc\n") { Absent => true Present { value: _ } => false }) + && (match parse_intent_scopes(text: "some-intent/v1\nscope=write code-name gunbc x\nscope=bogus\n") { Absent => true Present { value: _ } => false }) + && (match intent_frame_head(schema: "some-intent/v1" as NonEmptyStr, scopes: [] as List) { Absent => true Present { value: _ } => false }) +} diff --git a/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag b/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag index ce1f77fa37f..e20b6630401 100644 --- a/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag +++ b/dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag @@ -92,7 +92,7 @@ test fn a_frozen_intent_re_derives_the_filed_request_and_refuses_another_transac Absent => false Present { value: successor } => { let subject = D0Subject { group: FabricGroupA, hosts: fabric_group_hosts(g: FabricGroupA), successor: successor, cleanup: QuiescentReservedBaseline, term: second(count: 1000) } - let intent = d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-1" as NonEmptyStr) + let intent = (match d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-1" as NonEmptyStr) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) let text = d0_frozen_filing_text(intent_text: intent, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) let round_trip = match parse_d0_frozen_filing(text: text) { Present { value: f } => f.intent_text == intent && (f.execution_revision as String) == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" Absent => false } let other_revision = match d0_filed_request(text: text, group: FabricGroupA, escalation_id: "d0-esc-1" as NonEmptyStr, transaction: "tx-1" as NonEmptyStr, execution_revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as NonEmptyStr) { D0FiledRequestRefused { reason: r } => (r as String).contains("filed by revision aaaa") _ => false } diff --git a/dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag b/dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag index 00aea13fd0c..00135efdf3b 100644 --- a/dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag +++ b/dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag @@ -911,7 +911,7 @@ test fn a_filing_frozen_once_is_read_back_as_the_same_request_without_a_live_sub d0r_with_hash(scenario: fn(h) { d0r_with_remote(scenario: fn(dir, store) { let subject = D0Subject { group: FabricGroupA, hosts: fabric_group_hosts(g: FabricGroupA), successor: h, cleanup: QuiescentReservedBaseline, term: second(count: 1000) } - let text = d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-d0" as NonEmptyStr) + let text = (match d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-d0" as NonEmptyStr) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) let esc = "esc-d0-filing" as NonEmptyStr let absent_before = match d0_read_filing(store: store, escalation_id: esc) { D0FilingAbsent => true _ => false } let frozen_text = d0_frozen_filing_text(intent_text: text, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) @@ -964,30 +964,44 @@ test fn a_grant_whose_window_passed_before_the_claim_takes_no_claim_by_real_exec }) } -// THE DOOR DECIDES WHICH RUN THIS IS FROM THE STORE, BY REAL EXECUTION: with nothing frozen and the -// live candidate unkeyed (the row-store digest is still owed) a first run refuses before filing -- -// and freezes nothing; once a filing is frozen under the escalation, the same call is a rerun -// carrying the frozen request, with no live subject consulted; and the frozen filing under another -// transaction's name refuses, and so does a rerun executing another revision than the one that filed. +// THE DOOR DECIDES WHICH RUN THIS IS FROM THE STORE, BY REAL EXECUTION. With nothing frozen, a first +// run either refuses before filing and freezes nothing (no live subject yet), or -- the live V4.1 +// candidate is keyed since 2026-09-24 (test.claim.spark.v41_runtime_candidate +// the_live_candidate_is_keyed_end_to_end) -- freezes exactly the filing it asks the operator over. +// The claim holds in both worlds rather than assuming the one it was written in: an earlier revision +// asserted the refusal, went stale when the candidate was keyed, and was not noticed because it had +// never executed. Whatever was frozen, the same call is then a rerun carrying the frozen request with +// no live subject consulted; the filing under another transaction's name refuses, and so does a rerun +// executing another revision than the one that filed. +fn d0r_after_frozen(store: FabricStorageBinding, esc: NonEmptyStr, successor: ContentHash) -> Bool { + let other_revision = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as NonEmptyStr) { D0RunRefused { reason: r } => r.contains("filed by revision aaaa") _ => false } + let rerun = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { + D0Rerun { request: r } => content_hash_equal(left: r.subject.successor, right: successor) && (r.escalation_id as String) == (esc as String) + _ => false + } + let other = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-else" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { D0RunRefused { reason: r } => r.contains("tx-d0") _ => false } + other_revision && rerun && other +} + test fn the_door_reads_the_frozen_filing_to_decide_a_rerun_without_a_live_subject_by_real_execution() -> Bool { d0r_with_hash(scenario: fn(h) { d0r_with_remote(scenario: fn(dir, store) { let esc = "esc-d0-door" as NonEmptyStr - let first = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { - D0RunRefused { reason: r } => r.contains("row-store") || r.contains("refused") - D0FirstRun { request: _ } => false + match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { D0Rerun { request: _ } => false + D0FirstRun { request: live } => + (match d0_read_filing(store: store, escalation_id: esc) { D0FilingAt { text: _ } => true _ => false }) + && d0r_after_frozen(store: store, esc: esc, successor: live.subject.successor) + D0RunRefused { reason: _ } => { + let nothing_frozen = match d0_read_filing(store: store, escalation_id: esc) { D0FilingAbsent => true _ => false } + let subject = D0Subject { group: FabricGroupA, hosts: fabric_group_hosts(g: FabricGroupA), successor: h, cleanup: QuiescentReservedBaseline, term: second(count: 1000) } + let frozen = match d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-d0" as NonEmptyStr) { + Absent => false + Present { value: text } => match d0_freeze_filing(store: store, escalation_id: esc, intent_text: text, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { D0FilingFrozen => true _ => false } + } + nothing_frozen && frozen && d0r_after_frozen(store: store, esc: esc, successor: h) + } } - let nothing_frozen = match d0_read_filing(store: store, escalation_id: esc) { D0FilingAbsent => true _ => false } - let subject = D0Subject { group: FabricGroupA, hosts: fabric_group_hosts(g: FabricGroupA), successor: h, cleanup: QuiescentReservedBaseline, term: second(count: 1000) } - let frozen = match d0_freeze_filing(store: store, escalation_id: esc, intent_text: d0_intent_text(subject: subject, group: FabricGroupA, transaction: "tx-d0" as NonEmptyStr), execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { D0FilingFrozen => true _ => false } - let other_revision = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as NonEmptyStr) { D0RunRefused { reason: r } => r.contains("filed by revision aaaa") _ => false } - let rerun = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-d0" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { - D0Rerun { request: r } => content_hash_equal(left: r.subject.successor, right: h) && (r.escalation_id as String) == "esc-d0-door" - _ => false - } - let other = match d0_run_kind(store: store, g: FabricGroupA, escalation_id: esc, transaction: "tx-else" as NonEmptyStr, execution_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as NonEmptyStr) { D0RunRefused { reason: r } => r.contains("tx-d0") _ => false } - first && nothing_frozen && frozen && other_revision && rerun && other }) }) } diff --git a/dag/test/claim/spark/pair_serving_d0_witness_test.dag b/dag/test/claim/spark/pair_serving_d0_witness_test.dag index 117d765323f..4581041f4d0 100644 --- a/dag/test/claim/spark/pair_serving_d0_witness_test.dag +++ b/dag/test/claim/spark/pair_serving_d0_witness_test.dag @@ -28,6 +28,7 @@ import gunbc.spark.pair_serving_authority { import std.scoped_authorization { OperatorGrant, ScopedAuthorization, AuthorizationGranted, Unclaimed, AuthorizationScope, AttemptIdentity, AuthorizedAction, AuthorizationClaimState, ClaimedBy, CompletedBy, AbortedBy, + parse_intent_scopes, authorization_scope_eq, } import std.effect_grant { Read, Write, NamespacePosition, CodeNameTree } import gunbc.rung_drop { Standing, Retired } @@ -38,7 +39,7 @@ import gunbc.spark.pair_serving_d0 { HeadEndpointReading, HeadEndpointQuiet, HeadEndpointListening, HeadEndpointEngineProcess, HeadEndpointUnread, d0_held_lease, OccupancyReconciliation, OccupancyNotQuiet, DeclaredOccupantDrifted, OccupancyUnread, reconcile_occupancy, D0Decision, D0DecidedSuspend, D0DecidedRestore, D0DecidedFence, D0Cause, D0IncumbentLive, D0IncumbentUnidentified, D0OccupancyUnread, D0DeclarationUnavailable, - d0_decide, d0_decision_is_eligible, d0_authorized_action, d0_intent_text, + d0_decide, d0_decision_is_eligible, d0_authorized_action, d0_intent_text, d0_required_scope, d0_purpose, D0Population, D0PopulationAgrees, D0PopulationDiffers, d0_population, D0ClaimReading, D0ClaimAbsent, D0ClaimAt, D0ClaimUnreadable, D0Recovery, D0RecoverStart, D0RecoverFirstWrite, D0RecoverResume, D0RecoverComplete, D0RecoverAbort, D0RecoveryRefused, d0_recovery, @@ -386,16 +387,37 @@ test fn the_intent_text_is_canonical_over_the_operation() -> Bool { d0w_with_digests(scenario: fn(a, b, c) { d0w_with_four_hosts(scenario: fn(h1, h2, h3, h4) { let one = d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: c, term: 1000) - let t = d0_intent_text(subject: one, group: FabricGroupA, transaction: d0w_t_tx) - t == d0_intent_text(subject: d0w_t_subject_over(hs: [h4, h2, h1, h3], successor: c, term: 1000), group: FabricGroupA, transaction: d0w_t_tx) - && t != d0_intent_text(subject: d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: c, term: 1001), group: FabricGroupA, transaction: d0w_t_tx) - && t != d0_intent_text(subject: d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: a, term: 1000), group: FabricGroupA, transaction: d0w_t_tx) - && t != d0_intent_text(subject: one, group: FabricGroupA, transaction: "tx-other" as NonEmptyStr) + let t = (match d0_intent_text(subject: one, group: FabricGroupA, transaction: d0w_t_tx) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) + t == (match d0_intent_text(subject: d0w_t_subject_over(hs: [h4, h2, h1, h3], successor: c, term: 1000), group: FabricGroupA, transaction: d0w_t_tx) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) + && t != (match d0_intent_text(subject: d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: c, term: 1001), group: FabricGroupA, transaction: d0w_t_tx) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) + && t != (match d0_intent_text(subject: d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: a, term: 1000), group: FabricGroupA, transaction: d0w_t_tx) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) + && t != (match d0_intent_text(subject: one, group: FabricGroupA, transaction: "tx-other" as NonEmptyStr) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" }) && t.contains("suspend-pair-serving-authority") }) }) } +// THE INTENT STATES ITS SCOPE IN THE SHARED FRAME: a verifier that holds only the text reads the +// one required scope through std.scoped_authorization parse_intent_scopes, with no D0 knowledge; +// and the purpose the operator reads names that scope and the whole subject, so the approval is +// over what the store will check. +test fn the_intent_text_states_its_scope_in_the_shared_frame() -> Bool { + d0w_with_digests(scenario: fn(a, b, c) { + d0w_with_four_hosts(scenario: fn(h1, h2, h3, h4) { + let one = d0w_t_subject_over(hs: [h1, h2, h3, h4], successor: c, term: 1000) + let purpose = d0_purpose(subject: one, transaction: d0w_t_tx) as String + (match parse_intent_scopes(text: (match d0_intent_text(subject: one, group: FabricGroupA, transaction: d0w_t_tx) { Present { value: intent_text_w } => intent_text_w Absent => "d0-intent-refused" })) { + Present { value: f } => count(f.scopes) == 1 && all(f.scopes, sc => authorization_scope_eq(a: sc, b: d0_required_scope())) + Absent => false + }) + && purpose.contains("suspend-pair-serving-authority") + && purpose.contains("code-name:gunbc/spark/pair_serving_authority") + && purpose.contains(h1 as String) + && purpose.contains(d0w_t_tx as String) + }) + }) +} + // ── RECOVERY: THE JOIN OVER THE CLAIM AND THE AUTHORITY HISTORY ──────────────────────────────── fn d0w_rec_of(id: String, tx: String, lifecycle: ContentHash?, next: PairServingGroupAuthority) -> AuthorityTransitionRecord { diff --git a/src/v2/workflow/floor_route_gap.dag b/src/v2/workflow/floor_route_gap.dag index ba16aea0c6a..99f42584742 100644 --- a/src/v2/workflow/floor_route_gap.dag +++ b/src/v2/workflow/floor_route_gap.dag @@ -936,6 +936,14 @@ fn floor_route_gap_expectation_chunk_08() -> List { tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.the_production_route_settles_and_the_lease_governs_launch_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_authorization_witness.a_frozen_intent_re_derives_the_filed_request_and_refuses_another_transaction", operation: "DigestStdin", ground: NoMockResponse {} }, + tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.a_filing_frozen_once_is_read_back_as_the_same_request_without_a_live_subject_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, + tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.the_door_reads_the_frozen_filing_to_decide_a_rerun_without_a_live_subject_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, + tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.a_grant_whose_window_passed_before_the_claim_takes_no_claim_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, + tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.a_rerun_resumes_its_own_pending_state_and_restores_the_exact_active_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.spark.pair_serving_d0_real_execution.a_fence_and_a_restore_each_reach_their_terminal_by_real_execution", operation: "Dir", ground: NoMockResponse {} }, @@ -1025,6 +1033,10 @@ fn floor_route_gap_expectation_chunk_08() -> List { } } } + } + } + } + } } // The eight test.claim.materialization_store_local_wet_witness identities: the provider spine's diff --git a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag index f77811c82cd..ec0041a45fd 100644 --- a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag +++ b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag @@ -921,8 +921,8 @@ data unimported_bare_provider_dispositions: List List { function: "an_unclaimed_group_is_refused_at_the_claim_by_real_execution", expectation: ExpectedToHold {} }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.spark.pair_serving_d0_authorization_witness", function: "a_frozen_intent_re_derives_the_filed_request_and_refuses_another_transaction" }, + entry: "dag/test/claim/spark/pair_serving_d0_authorization_witness_test.dag", + function: "a_frozen_intent_re_derives_the_filed_request_and_refuses_another_transaction", + expectation: ExpectedToHold {} + }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.spark.pair_serving_d0_real_execution", function: "a_filing_frozen_once_is_read_back_as_the_same_request_without_a_live_subject_by_real_execution" }, + entry: "dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag", + function: "a_filing_frozen_once_is_read_back_as_the_same_request_without_a_live_subject_by_real_execution", + expectation: ExpectedToHold {} + }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.spark.pair_serving_d0_real_execution", function: "the_door_reads_the_frozen_filing_to_decide_a_rerun_without_a_live_subject_by_real_execution" }, + entry: "dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag", + function: "the_door_reads_the_frozen_filing_to_decide_a_rerun_without_a_live_subject_by_real_execution", + expectation: ExpectedToHold {} + }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.spark.pair_serving_d0_real_execution", function: "a_grant_whose_window_passed_before_the_claim_takes_no_claim_by_real_execution" }, + entry: "dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag", + function: "a_grant_whose_window_passed_before_the_claim_takes_no_claim_by_real_execution", + expectation: ExpectedToHold {} + }, WetScheduledClaim { identity: WitnessIdentity { module_path: "test.claim.spark.pair_serving_d0_real_execution", function: "the_production_route_settles_and_the_lease_governs_launch_by_real_execution" }, entry: "dag/test/claim/spark/pair_serving_d0_real_execution_witness_test.dag",