From 990e8d2c70bfaea8450c56d075b800e8477be27d Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 1 Aug 2026 13:53:11 +0000 Subject: [PATCH] Sole modeled publisher (slice 1): authority, shadow witness, authorization profile. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-home publication placement onto std.authorization_profile.PublicationAdmissionRequest, model git Push/Commit transport shapes, and add read-only shadow replay — no live push path changes. Stamp sole-publisher roster paths deduped against main (#7580 repair rows and post-#7574 additions). Register design doc in doc graph roots with 2026-08-01 incident specimens. Co-authored-by: Cursor --- DESIGN.md | 4 +- dag/extdeps/git/publication_transport.dag | 90 +++++++ dag/gunbc/ci_deploy_access.dag | 25 +- dag/gunbc/ci_deploy_target_host.dag | 32 +-- dag/gunbc/design_document.dag | 4 +- dag/gunbc/doc_graph_roots.dag | 7 + dag/gunbc/host_effect_realize.dag | 17 +- dag/gunbc/host_identity_converge.dag | 2 +- dag/gunbc/live_deploy/apply.dag | 4 +- dag/gunbc/live_deploy/readiness.dag | 4 +- dag/gunbc/principal_projection.dag | 52 ++++ dag/gunbc/publication_grant.dag | 115 ++++++-- dag/gunbc/srv3_host_effect_apply.dag | 2 +- dag/std/access.dag | 87 +++++- dag/std/authorization_profile.dag | 198 ++++++++++++++ dag/std/effect_grant.dag | 16 +- dag/std/visibility_grant.dag | 175 ------------ ...ess_authorization_profile_witness_test.dag | 81 ++++++ dag/test/claim/access_iam_validation_test.dag | 33 ++- dag/test/claim/access_validation_test.dag | 46 +++- .../claim/ci_deploy_observed_wet_test.dag | 2 +- dag/test/claim/ci_deploy_witness_test.dag | 2 +- dag/test/claim/crosstree_probe_test.dag | 2 +- .../deploy_access_privilege_witness_test.dag | 7 +- .../deploy_mutation_gate_witness_test.dag | 25 +- .../claim/grounded_principal_witness_test.dag | 4 +- .../principal_projection_witness_test.dag | 59 +++++ .../claim/publication_placement_gate_test.dag | 19 +- .../publication_publisher_witness_test.dag | 250 ++++++++++++++++++ .../claim/visibility_grant_witness_test.dag | 169 ------------ dag/tools/publication_placement_gate.dag | 92 ++++--- dag/tools/publication_publisher.dag | 241 +++++++++++++++++ dag/tools/publication_push_shadow.dag | 116 ++++++++ docs/plans/sole-modeled-publisher-design.md | 101 +++++++ 34 files changed, 1532 insertions(+), 551 deletions(-) create mode 100644 dag/extdeps/git/publication_transport.dag create mode 100644 dag/gunbc/principal_projection.dag create mode 100644 dag/std/authorization_profile.dag delete mode 100644 dag/std/visibility_grant.dag create mode 100644 dag/test/claim/access_authorization_profile_witness_test.dag create mode 100644 dag/test/claim/principal_projection_witness_test.dag create mode 100644 dag/test/claim/publication_publisher_witness_test.dag delete mode 100644 dag/test/claim/visibility_grant_witness_test.dag create mode 100644 dag/tools/publication_publisher.dag create mode 100644 dag/tools/publication_push_shadow.dag create mode 100644 docs/plans/sole-modeled-publisher-design.md diff --git a/DESIGN.md b/DESIGN.md index 11714fa121e..cfa5ec1f1b2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -113,10 +113,10 @@ hollow alias (minimality ≠ grounding) · state-space conflation (an `Option`/` - **machine-shape orthogonal scheduling — one realization kernel over faithfully modeled machine facts, with locality (§2).** DRAFT for review; model-before-implement, each phase names its trigger. Its authority transfers to the std/extdeps carriers it names when Phase 1 lands, at which point this md dissolves into a registered `gunbc.plan.Plan` row (§6). → [machine-shape scheduling design](docs/plans/machine-shape-orthogonal-scheduling.md) - **namespace-only name resolution — the containment tree is the single naming authority (operator-signed 2026-07-06).** Supersedes resolver-graph-major §1c's *import-name-universe* ("own declarations ∪ direct import lists" visibility), carrying forward its mechanics unchanged (binding-edge owner, unbound/double-bound = error, patterns-via-scrutinee, no expected-type picker). One structure — **syntactic containment** — induces everything at once: qualified name = nesting position (`T{a}` → `a` *is* `T.a`; `T{a{a}}` → inner *is* `T.a.a`, so Rule-2 no-ambiguity holds by construction), reference = lexical lookup up the ancestor chain (a sibling module is visible, its members projected `node.Symbol`), `.` = projection one level down (field / module-member / variant — one op), and `.` *is* the `std.induction` sub-value relation (descent evidence). So one content-addressed tree grounds THREE consumers — resolution *walks* it, content-addressing *hashes* it, termination reads its *sub-value* edges (§2 Realization: one structure, N consumers). Frontend BUILDS the tree from nesting (pure structure, zero resolution logic); backend WALKS it (lexical up, project down) — no heuristics to tune (the tuning risk = resolving against a flat index instead of the tree). Two governing rules (operator, 2026-07-06): **no dual representation / minimal at every layer; no ambiguity at any layer.** Rides on `SymbolIndex` = "the containment tree materialized" (`type-env-single-authority`, lively-raven lane) — the loyal-heron scaling gate that formerly blocked resolver surgery was **discharged at #6809** (per §7.5 of the design doc: "the lane is no longer blocked on it"); `import-scoped` (§1c, today) and `namespace-only-Y` (position-info uniqueness: expected type filters a variant to one, never picks) are two walk-rules over one *fill-agnostic* index (fill = whole tree; policy gates lookup, never fill). Census: 98% of names globally unique → always bare; the residue is v1-seed forks (resolve by subtree, free) + github-style variants ((Y) context-resolves); no consolidate-now forks (the census's 2 same-name flags proved to be a homonym + whole-file v1-seed duplication, not genuine cross-tree §3 forks). Terminal step: delete the `import` grammar + supporting code → `import` becomes a *parse error*, deps derived from `container.member` references (Rule-1 end-state). → [namespace-only resolution design](docs/plans/namespace-resolution-design.md) - **fleet-reconcile spine — one grain-agnostic membership diff (deploy · fleet · session).** ONE `membership_reconcile` reuses `std.change.keyed_two_way_diff` verbatim (no fork): desired set vs observed set keyed by member IDENTITY — Added/Modified → upsert, Removed → teardown-or-refuse, Unchanged → noop (absence of a hunk). A member may be a service/unit/host OR a Session (operator: 'it should all be the same'), so a new member type is a new instantiation of the same fn with its own `(key_of, key_eq, value_eq, ownership_of)` bundle — zero spine change (a Realization, §2; a forked reconcile means the genericity bought nothing). R5 teardown-owned-only is a CONSTRUCTION WALL: a Removed non-owned member yields `MemberTeardownRefused`, which has no effect arm in any apply dispatch, so the bad state is unwritable — never a post-check, never 'assume owned' (the absorbing fallback §5 forbids); ownership-unknown REFUSES too, typed/located/counted. Ownership is extracted as its own single authority (`gunbc.ownership.Ownership`) and `live_deploy`'s `DeploymentStep` re-grounds onto it (pass 2, a declared dissolution trigger — never a second `Owned|Ensured`). Pass 1 (spine + R5 + a synthetic discriminating witness the degenerate apply/retract poles cannot exercise) landed. → [membership-diff reconcile spine design](docs/plans/membership-diff-reconcile-spine-design.md) -- **node/subtree visibility grants — two verbs (`Reference`, `Publish`) in the same grant algebra, not a parallel mechanism.** `Reference` gates whether a projection/reference edge may be formed within one compiled program; `Publish` gates whether source appears in an audience storage realization. Both reuse `Grant`/`admit_effect_go`; `AudienceScopeTree` converges the existing cache visibility scope, while concrete `std.realization.Placement` remains a different redundant-computation axis. One graph has N storage projections: a private repo is not a second graph. Rule 1 makes publication visibility monotone along references; Rule 2 makes privacy downward-closed on containment, nearest ancestor winning. A World projection renders private content absent by default, never a churn/name-leaking stub; commitment stubs are later opt-in. `Reference` keeps today's permissive `LegacyOpen` frontier, while new undeclared `Publish` content defaults to the narrowest root; Stage 0 bulk-stamps today's public corpus before the guard activates. Stage 0 is file-grain/two Git roots/push guard; Stage 1 checks the composed graph; Stage 2 rides the module-storage binding. A public-only guard reports only local-marker/dangling-reference coverage and never impersonates the composed-root closure. The locked extension is a **publication capability product, not a ladder**: source/interface disclosure, artifact delivery, local/remote execution, integrity, reproducibility, existence/churn disclosure, revocation/erase, and placement/trust are independent policy axes. An emitted artifact is not source disclosure but remains inspectable; client-held key means plaintext access, distinct from remote/attested key custody. Locked blobs use AEAD with subject/interface/version/audience/policy context bound as associated data plus confidentiality-and-integrity-preserving per-audience key wraps; wrong key/context/authentication is typed refusal. Per-statement holes remain refused; interface/realization is the cut; size padding and `Secret`/`Pii`→`SecretRef` construction constraints remain. Commitments never promise zero leakage: length, timing, interface shape, wrap count, access pattern, low-entropy guessing, and churn stay in a named threat model. Remote execution and SCM diagnostics obey information-flow/noninterference + explicit declassification; metering prices an oracle but does not secure it, and unauthorized audiences receive only a non-leaking diagnostic projection. Cryptographic erase is conditional on every key/wrap/backup/cache/derivative/recovery path being sanitized and the result validated, never “delete one key.” → [node/subtree visibility grants design](docs/plans/node-subtree-visibility-grants.md) +- **one authorization kernel, several typed request profiles (operator directive 2026-08-01).** `std.access.AccessRequest` carries subject, action, object, typed context, and typed evidence; `AccessPolicy` is the sole policy-to-decision seam, `decision_meet` the sole conjunction, and every denial carries a typed cause at structured request coordinates. Effect, reference, publication admission, disclosure, and sealed execution are exact request products projected into that kernel, never independent allow/deny folds. Principals project external evidence — OIDC issuer/subject and observed POSIX effective principal — rather than being invented internal identities. The provisional gunbc#7541 `Verb::Reference` / `Verb::Publish` / `AudienceScopeTree` carriers dissolved: publication audiences are sets with join/meet, because real audiences can overlap without either containing the other. The Stage-0 publication wall preserves its accepted/refused behavior through `PublicationAdmissionRequest` and the one kernel. The locked extension remains a **capability product, not a ladder**: source/interface disclosure, artifact delivery, local/remote execution, integrity, reproducibility, existence/churn disclosure, revocation/erase, and placement/trust are independent policy axes. → [node/subtree visibility grants design](docs/plans/node-subtree-visibility-grants.md) - **effect grants over namespaces — dissolve `Hermetic | Wet` into (frame × verb × subtree).** The 2-valued `Hermetic | Wet` enum conflates four axes (input closure / replayability, output reach / interference, handler binding, selection eligibility) — a §3 state-space conflation with proto-envelopes already forked (the hand-rolled `workspace_root` containment gate, `std.resources.ResourceHandle`, `AuthScope`, `LiveTreeDisposition`). End shape (§3, cited not minted): an effect target is a position in a containment tree that already exists — filesystem paths, URIs, `/proc`, service-operation paths, and the unifying case **code names themselves** (the containment tree the namespace-resolution lane makes the single naming authority); permission is a grant of (verb × subtree) attached to a `Frame`; admissibility is the namespace prefix relation — the same `⊑` the resolver walks, content-addressing hashes, and termination reads, so effects become the **fourth consumer** of the one containment structure. Dispatch checks the envelope fail-closed (`EffectOutsideGrant`, a typed/located/counted refusal, never a silent widen — §5); *replayable* / *isolated* / `LiveTreeDisposition` become **derived** projections, and `Hermetic | Wet | Record` survive only as named envelope presets, deleted at the end. Frame-containment is graded on the §5 construction/validation axis (`LifecycleByConstruction` vs `LifecycleByConvention`). No code lands from the design doc; the FLAG-A interim is the only near-term consumer. → [effect-namespace-grants design](docs/plans/effect-namespace-grants.md) - **shell → intent: the intent layer is language-blind (operator-aligned 2026-07-17; census/routing consolidated onto sibling authorities 2026-07-19).** §3/§4 restated for the shell case: the `.dag` intent may not name a target language — a workflow is an ordinary dependency graph over modeled operations (§4: a program is `Node`+`Edge`); *the intent IS the `.dag` graph* (operator), and `src/v2/std/orchestration.dag` `Pipeline` is optional sugar, never the authority. Two distinct downstream layers, not to be conflated: **emit** (§4 — `emit(intent, Bash)`, one grammar read backward, target as a *parameter*) and **realization** (§2 — pure-spec → host-effect, N transports `LocalShell`/`SshShell`, `dag/gunbc/host_effect_realize.dag`). **Routing (the load-bearing correction, operator review 2026-07-19): most sites do NOT emit bash.** Runtime-present sites (a gunbc runtime / typed transport is on the path) realize as typed service ops / typed transports / binary-interpreted effect-plan via `host_effect_apply` — no bash emitted (precedent already in tree: `floor_diff_observe` calls `git.Core.DiffUnified0` directly; `host_identity_converge` funnels through `host_effect_apply`); only foreign executors (GHA `run:`, cron, git hooks) and pre-runtime bootstrap windows render through the bounded v2 bash grammar rows. The authority for that routing is `authorize_shell_emission` (provisioning-window lane); the per-site residual census + arc-completion scoping is `shell-to-dag-residual-census-and-arc-completion.md` (witty-ibex-317) — DESIGN carries no parallel copy of either. **Invariant (enforceable):** the intent imports no language-construction vocab — emit-internal; `src/v2/lens/realization_vocabulary_containment.dag` confines it (LANDED #6854). The lens confines by MODULE, and naming what it actually reads is the whole point of §3's cite-the-symbol rule: `target_ast_vocab_module_prefixes` = `v2.extdeps.languages.bash`, `v2.workflow.bash`, plus `target_ast_vocab_modules` = `extdeps.languages.typescript.program`. The bash vocabulary is `v2.extdeps.languages.bash_build`'s `bash_build_*` constructors and the `v2.workflow.bash_command_fold_serialize` backend. *(Corrected 2026-07-31: this sentence previously listed the confined vocab as ``bash_build``, ``bash_command_fold_serialize``, ``the ShellStmt/ShellWord/ShellProgram coproduct``, ``serialize_bash``. The first two are real modules; the last two NAME NOTHING — the bash AST is built as generic `Node` (every `bash_build_*` fn returns `Node`), there is no ShellStmt/ShellWord/ShellProgram coproduct, and no `serialize_bash` is defined anywhere in the tree. Both were dissolved by the typed-WitnessBin migration (Phase D, 2026-07-14), which the `dag/tools/*` transport notes record and this row did not follow. The WALL WAS NEVER AFFECTED — the lens confines module prefixes, not these names, so nothing was unguarded; what rotted was the description of it, in the sentence declaring the invariant. That is the §3 positional/symbolic citation class exactly: a name-level claim in the canonical authority that a grep would have decided, sitting false while the mechanism it describes stayed correct. Found by a corpus citation sweep, not by anyone touching this row.)* Meta-exec confinement is **PARTIAL**: Wave A1 (#6900) module-isolated `shell.Exec.Run/.Check` and the import wall now has an empty exception roster; the still-open construction half is symbol/reference-level visibility that prevents allowed realization-edge modules from forming arbitrary transport values. *(Corrected 2026-07-26: this read "`host_language_transport_script` lens inert, `fail_closed_lockdown.dag`". That lens is **LIVE** — activated by #7184 as a per-PR `ReadsLiveTree` consumer that reds a new raw literal at an enrolled `Run` position — and the transport-script hole it was cited for is **closed by construction on the `ShellOnHost` route only**: `ShellOnHost.script` is the `RetainedShellScript` record and the free minter is deleted — while `shell.Exec.Run` itself still declares a transparent `TransportScript` brand over `String`, so a direct `as TransportScript` cast stays writable from any module and that sink is lens-guarded, not construction-walled (corrected 2026-07-27, `review 43600`; closing it is the meta-exec confinement milestone). The lens stays deliberately green on `ComputedApplication`, so it backstops literal blobs rather than being the wall. What remains open is the construction confinement named above, not the already-landed module-import wall.)* **Carriers → `Do{effect}` (never a command string, host-effect-orchestration.md):** the anemic `String` leaves are `host_effect.ShellCommand{script}`, `std.orchestration.Run.command`, and `host_effect.BootstrapFragment.script` — add-replacement → migrate all consumers → delete at arc close (§5 construction; `host_effect.dag` is load-bearing, the delete escalated). **Phases:** 0 sidecar delete LANDED (#6831) → 1 agnostic `While`/`BoundedPoll`/`Retry` emit PARTIAL (#6832; corrected 2026-07-26, verified against main — `While` framing has exactly one production consumer, `ci_floor_peak_emit`, whose cond and body are still raw shell strings; `BoundedPoll` has zero production construction sites, test-only; #7303 makes Retry refuse multi-step bodies, present redirect/capture, changed template log sources, and three-or-more (N-level) escalation shapes; #7318 additionally refuses every nonempty source `Run.env` while preserving empty-env emission bytes, proven by executing nonempty-refusal and empty-env accepted controls. Phase 1 remains PARTIAL only because the live `While` consumer has two raw command leaves, `BoundedPoll` is test-only, and the modeled scope still needs need-driven adoption or explicit narrowing) → 2 model runtime-present sites as typed effects on `host_effect_apply` + emit the foreign-executor/bootstrap residue → 3 wall green (bash-AST containment + meta-exec confinement). → [shell → intent design](docs/plans/shell-intent-emit-realization-design.md) -- **srvN build-cache provisioning on host-standup subsumption spine.** Legacy `ctrl-sccache.service` is misconfigured; `ci_release_build_script()` absorbing fallback (`CARGO_BUILD_JOBS=1` then `-u RUSTC_WRAPPER`) masks the deficit (§5). Design anchor: provision sccache as a host-effect ensure on the assimilation spine (P1b after `RunnerDeploySlot`), generalizing srv3's `WorkflowEnsureActuatorToolchain` to srvN; `ProvisionBuildCache { catalog_id }` routes through `host_effect_apply`; verdict fold is `ProvisionConverged | ProvisionRefused` only. **PLACEMENT IS A VERDICT AXIS (landed):** sccache executes cache-miss compiles as *children of the server*, so the server's cgroup is the cgroup every compile on the host runs in — a responsive daemon proves nothing about where it runs, and collapsing the two into one `DaemonStatsOk` arm is the §5 state-space conflation that let a lazily auto-spawned server (kept alive by `SCCACHE_IDLE_TIMEOUT=0`) strand itself in a dead slot's cgroup and bound all five live slots' compiles at one slot's cap. `BuildCacheServerPlacement = ServerManagedUnitOwned | ServerRunnerOwned{slot,lifecycle} | ServerUnsupervised | ServerPlacementAbsent | ServerPlacementUnknown` gates `ProvisionConverged`; unknown placement refuses rather than assuming owned, and BOTH runner lifecycles refuse (an active slot means birth ordering failed; a retired slot means birth ordering AND retirement both failed). It is derived from an endpoint-owner observation homed with the deployed-instance model in `gunbc.build_cache_instance` (`BuildCacheInstance` = host x consumer class x instance name, distinct from the CATALOG identity `sccache_local_id`), because process name plus cgroup cannot tell a server from the transient clients every compile spawns — the owner is the process holding the LISTEN on the configured endpoint. **LIVE RECEIPT 2026-08-01 (read-only, srv1/srv2/srv3):** NO host has a unit-owned CI cache; the birth defect is fleet-wide. srv1's owner is pid 3975946, 31.2 days old, in RETIRED slot `srv1-10` (inactive+disabled) with `max 229780` / `oom_kill 12` and a live runner's `rustc` resident beside it; srv2's and srv3's owners sit in ACTIVE slots and so self-limit only because ephemeral runners recycle them. srv4 was unreachable and is carried as typed `ReachRefused`, never as absent. A measured correction landed with it: `--show-stats` does NOT auto-start a server (private-endpoint controls, both UDS and TCP, 0 to 0 listeners) — it answers plausibly with NO server in existence, so stats may only ever be supplemental data read after ownership is grounded; a real COMPILE is what spawns a server, inheriting the invoking cgroup. The budget consequence is a first-class `compile_pool` child of the host allocation tree sized by the cited jobserver authority (`jobserver_build_pool_bytes`) — a per-slot memory cap does *not* bound compile RSS — with `CompilePoolPlacement` making the pool's size derive from the declared topology rather than a fudge. STEP 2 (fallback removal) operator-sequenced after T4 live read-back. → [srvN build-cache provisioning design](docs/plans/srvn-buildcache-provisioning-design.md) · [placement step-1 live receipt](docs/plans/build-cache-placement-receipt.md) +- **srvN build-cache provisioning on host-standup subsumption spine.** Legacy `ctrl-sccache.service` is misconfigured; `ci_release_build_script()` absorbing fallback (`CARGO_BUILD_JOBS=1` then `-u RUSTC_WRAPPER`) masks the deficit (§5). Design anchor: provision sccache as a host-effect ensure on the assimilation spine (P1b after `RunnerDeploySlot`), generalizing srv3's `WorkflowEnsureActuatorToolchain` to srvN; `ProvisionBuildCache { catalog_id }` routes through `host_effect_apply`; verdict fold is `ProvisionConverged | ProvisionRefused` only. STEP 2 (fallback removal) operator-sequenced after T4 live read-back. → [srvN build-cache provisioning design](docs/plans/srvn-buildcache-provisioning-design.md) - **srv4 fleet subsumption — manual-gap receipts (2026-07-22).** srv4 was installed and subsumed through the modeled BMC virtual-media path (PR #7027: 5 runners live, rootless Docker + sccache, seeded-media boot-fidelity fixes root-caused live); every step still performed by hand is typed as a modeling gap in the registered plan `gunbc.plans.fleet_subsumption_manual_gaps` (A1 MaterializedSecret lifecycle, A2 reach-secrets ADC, A3 SecretRef liveness, B1 runner-slot provisioning, B2 runner user, B3 fresh-host prereqs, B4 install actuator), each with an acceptance bar and RED; the plan dissolves item-by-item and fully retires when srv5 subsumes with zero hand-run shell. The BMC credential-rotation gap analysis that seeded the lane: → [srv4 BMC onboarding gap](docs/plans/srv4-bmc-onboarding-gap.md) - **gunb.ai site subsumption — hosting + frontend emitted from `.dag` (operator-directed 2026-07-20).** Subsume `gunb-ai/frontend`: the public site's HTML/CSS/JS become emitted, drift-gated artifacts in this repo, published to GitHub Pages at `gunb.ai` (DNS already points at Pages IPs; cutover is Pages-config-only; the frontend repo archives). Register thesis, developed across nine operator passes and carried as a signable checklist + behavioral-intricacy law: *quiet at arm's length; responsive up close; every response true* — simplicity is a D0/D1 property, intricacy a D2/D3 property of BEHAVIOR (coverage × consistency × timing, the Discord re-read), and every intricate thing shown or answered derives from a real repo fact. Phase-0 slices LANDED under `dag/gunbc/site/` (principles → tokens → interaction → mark → constellation → moodboard → theme_transition → starlight/accent_study), proven by execution with RED controls: themes as total role→material assignments (night = warm starlight on indigo, day = indigo ink on paper, one model, two projections; totality/area/contrast witnessed), the glare envelope (bright OR chromatic, never both — the operator-rejected gamer cyan is the negative control and provably absent from emissions), still-until-touched by construction (MotionTrigger has no ambient variant), the universal theme reframe derived from the same BuildRule rows (role-routed colors cross-fade for free on one token clock; an unthemed color is a counted census row; the data-theme watcher pins the flip so the fade cannot race the UA restyle — measured, snap → 12–16-frame blend), and the accent decision grounded in `extdeps.astronomy.stellar_classification` (cited MK/Harvard table; no green stars exist so green enters only as verdigris material; M self-prunes at the envelope; candidates O/A/G/K rendered from identical fact rows). Remaining: checklist row-by-row sign-off, primitives.html at every depth, mark candidates B–D, craft-floor scales, generated-artifact registration, Pages workflow + cutover receipt (discharges the `2-stateless-frontend` milestone-A shape on the public domain). → [site subsumption design](docs/plans/gunb-ai-site-subsumption-design.md) - **hierarchical design-register library — lift `gunbc.site` L1/L2 into `gunbc.design.*` (DOC-ONLY until #6927 merges).** PR #6927 proves the register on gunb.ai; the follow-on extracts site-independent L1 house walls + L2 role-material machinery (lift-not-fork), re-grounds contrast at L0 (WCAG/OKLCH, dissolving the HSL lightness-gap proxy in `theme_contrast_ok`), and stages L4 archetypes + `DataVizArea` behind second-consumer discipline. Phase A fires on #6927 merge with an emission-identical witness. The **palette catalogue** rode ahead additively (operator 2026-07-21): `extdeps.color.srgb` (CSS Color 4 cite; hex = a serialization of `Srgb8`, HSL→sRGB re-homed out of the WCAG module) → `gunbc.design.palette` (approval IS the computed WCAG grade — ordered figure-on-ground pairings total by construction) → `gunbc.design.catalog` (first entry island-pastel incl. the derived `loam` ink), witnessed by execution; its measured receipts (HSL-S misreads pastel tints as neon — ivory C 0.007 refused while `day_canvas` C 0.008 passes; gamer-cyan C 0.088 < mint C 0.142, so the envelope must be a per-area L×C region) are A.2's discriminating witness — see plan §2.2. → [design-register library](docs/plans/design-register-library.md) diff --git a/dag/extdeps/git/publication_transport.dag b/dag/extdeps/git/publication_transport.dag new file mode 100644 index 00000000000..3dfeaa6e40f --- /dev/null +++ b/dag/extdeps/git/publication_transport.dag @@ -0,0 +1,90 @@ +module extdeps.git.publication_transport + +import std.types { Bool, CommitSha, GitRef, Int, List, NonEmptyStr, String } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "git-scm.com/docs/git-push" + } +} + +data git_commit_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "git-scm.com/docs/git-commit" + } +} + +data git_publication_transport_note: String = "Modeled upstream publication write operations de-fused from git.Core. PushRefUpdate and RecordCommit are the interface shapes for writing to a remote and recording a local commit respectively; the shell argv on each operation is ONE transport handler, never the semantic model. Consumers that need publication safety must route through gunbc.publication_publisher and admit an admitted public projection before binding a transport handler — raw argv push remains observable for shadow replay only in slice 1." + +type GitRefUpdateSpec { + local_ref: GitRef + remote_ref: GitRef +} + +type GitPushRequest { + remote: NonEmptyStr + ref_updates: List +} + +type GitCommitRequest { + message: NonEmptyStr + allow_empty: Bool +} + +fn shape_git_push_refspec(update: GitRefUpdateSpec) -> String { + join([update.local_ref as String, ":", update.remote_ref as String], "") +} + +fn shape_git_push_argv(request: GitPushRequest) -> List { + fold(request.ref_updates, init: ["git", "push", request.remote as String], f: fn(argv, update) { + append(argv, items: [shape_git_push_refspec(update: update)]) + }) +} + +fn shape_git_commit_argv(request: GitCommitRequest) -> List { + if request.allow_empty { + ["git", "commit", "--allow-empty", "-m", request.message as String] + } else { + ["git", "commit", "-m", request.message as String] + } +} + +service git.PublicationTransport { + operation PushRefUpdate { + input { + remote: NonEmptyStr + refspec: NonEmptyStr + } + output { + exit_code: Int from "exit_code" + stderr: String from "stderr" + success: Bool from "exit_success" + } + transport shell { argv: ["git", "push", "{remote}", "{refspec}"] } + exit { + 0 => Unit + nonzero => String "Push failed" + } + } + + operation RecordCommit { + input { + message: NonEmptyStr + allow_empty: Bool = false + } + output { + sha: CommitSha from "stdout" + success: Bool from "exit_success" + stderr: String from "stderr" + } + transport shell { argv: ["git", "commit", "-m", "{message}"] } + exit { + 0 => Unit + nonzero => String "Commit failed" + } + } +} diff --git a/dag/gunbc/ci_deploy_access.dag b/dag/gunbc/ci_deploy_access.dag index a24382a1b99..38fb7fc7ba8 100644 --- a/dag/gunbc/ci_deploy_access.dag +++ b/dag/gunbc/ci_deploy_access.dag @@ -1,7 +1,7 @@ module gunbc.ci_deploy_access import std.types { NonEmptyStr, List, String, Bool } -import std.access { AccessDecision, Permit, Deny } +import std.access { AccessDecision, Permit, Deny, access_policy_refusal, access_refusal_reason } import std.disposition { Disposition, Scaffold, RealizationDispatch, Terminal } import std.decl_ref { DeclarationRef, WholeDeclaration } import std.materialization_ladder { Frame, SharedStateFrame } @@ -295,26 +295,21 @@ fn deploy_access_sudo_miss_reason(access: DeployAccess) -> String { fn deploy_access_check(access: DeployAccess, actor: DeployAccessActor) -> AccessDecision { let expected = grounded_posix_principal_name(p: access.principal) if actor.observed_name != expected { - Deny { - reason: Present { - value: concat( + access_policy_refusal( + profile: "deploy-access", subject: actor.observed_name, action: "execute", object: expected, + reason: concat( concat( concat(deploy_access_refusal_prefix, " — principal mismatch expected="), expected ), concat(" actual=", actor.observed_name) - ) - } - } + ), + ) } else { if actor.sudo_granted { Permit } else { - Deny { - reason: Present { - value: deploy_access_sudo_miss_reason(access: access) - } - } + access_policy_refusal(profile: "deploy-access", subject: actor.observed_name, action: "execute", object: expected, reason: deploy_access_sudo_miss_reason(access: access)) } } } @@ -322,11 +317,7 @@ fn deploy_access_check(access: DeployAccess, actor: DeployAccessActor) -> Access fn deploy_access_decision_reason(d: AccessDecision) -> String { match d { Permit => "" - Deny { reason: r } => - match r { - Present { value: why } => why - Absent => deploy_access_refusal_prefix - } + Deny { refusal: refusal } => access_refusal_reason(refusal: refusal) } } diff --git a/dag/gunbc/ci_deploy_target_host.dag b/dag/gunbc/ci_deploy_target_host.dag index 17e9ad4ca97..ea7556ba3af 100644 --- a/dag/gunbc/ci_deploy_target_host.dag +++ b/dag/gunbc/ci_deploy_target_host.dag @@ -1,7 +1,7 @@ module gunbc.ci_deploy_target_host import std.types { String, NonEmptyStr, Bool, List } -import std.access { AccessDecision, Permit, Deny } +import std.access { AccessDecision, Permit, Deny, access_policy_refusal, access_refusal_reason } import std.disposition { Disposition, Scaffold, SingleAuthority } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.shell @@ -57,11 +57,7 @@ fn deploy_target_host_observed() -> DeployTargetHostObservation { fn deploy_target_host_decision_reason(d: AccessDecision) -> String { match d { Permit => "" - Deny { reason: r } => - match r { - Present { value: why } => why - Absent => deploy_target_host_refusal_prefix - } + Deny { refusal: refusal } => access_refusal_reason(refusal: refusal) } } @@ -70,7 +66,7 @@ fn deploy_target_host_check( observed: DeployTargetHostObservation ) -> AccessDecision { if !deploy_target_ssh_proof_structurally_valid(proof: proof) { - Deny { reason: Present { value: deploy_target_host_refusal_ssh } } + access_policy_refusal(profile: "deploy-target-host", subject: "runner", action: "deploy", object: "target-host", reason: deploy_target_host_refusal_ssh) } else { if deploy_target_host_apply_permitted( proof: proof, @@ -81,9 +77,9 @@ fn deploy_target_host_check( } else { match proof { RunnerIdentityOnHost { host: id } => - Deny { - reason: Present { - value: concat( + access_policy_refusal( + profile: "deploy-target-host", subject: observed.runner_name, action: "deploy", object: id, + reason: concat( concat( concat(deploy_target_host_refusal_prefix, " — "), deploy_target_host_refusal_local @@ -95,21 +91,19 @@ fn deploy_target_host_check( concat(" runner=", observed.runner_name) ) ) - ) - } - } + ), + ) SshExplicitHost { host: id, ssh_host: h } => - Deny { - reason: Present { - value: concat( + access_policy_refusal( + profile: "deploy-target-host", subject: observed.runner_name, action: "deploy", object: id, + reason: concat( concat(deploy_target_host_refusal_prefix, " — "), concat( deploy_target_host_refusal_ssh, concat(" expected=", concat(id, concat(" ssh_host=", h))) ) - ) - } - } + ), + ) } } } diff --git a/dag/gunbc/design_document.dag b/dag/gunbc/design_document.dag index 8a5c4f2dd6d..717a5b22485 100644 --- a/dag/gunbc/design_document.dag +++ b/dag/gunbc/design_document.dag @@ -164,10 +164,10 @@ fn open_threads_blocks() -> List { li(text: "**machine-shape orthogonal scheduling — one realization kernel over faithfully modeled machine facts, with locality (§2).** DRAFT for review; model-before-implement, each phase names its trigger. Its authority transfers to the std/extdeps carriers it names when Phase 1 lands, at which point this md dissolves into a registered `gunbc.plan.Plan` row (§6). → [machine-shape scheduling design](docs/plans/machine-shape-orthogonal-scheduling.md)"), li(text: "**namespace-only name resolution — the containment tree is the single naming authority (operator-signed 2026-07-06).** Supersedes resolver-graph-major §1c's *import-name-universe* (\"own declarations ∪ direct import lists\" visibility), carrying forward its mechanics unchanged (binding-edge owner, unbound/double-bound = error, patterns-via-scrutinee, no expected-type picker). One structure — **syntactic containment** — induces everything at once: qualified name = nesting position (`T\{a\}` → `a` *is* `T.a`; `T\{a\{a\}\}` → inner *is* `T.a.a`, so Rule-2 no-ambiguity holds by construction), reference = lexical lookup up the ancestor chain (a sibling module is visible, its members projected `node.Symbol`), `.` = projection one level down (field / module-member / variant — one op), and `.` *is* the `std.induction` sub-value relation (descent evidence). So one content-addressed tree grounds THREE consumers — resolution *walks* it, content-addressing *hashes* it, termination reads its *sub-value* edges (§2 Realization: one structure, N consumers). Frontend BUILDS the tree from nesting (pure structure, zero resolution logic); backend WALKS it (lexical up, project down) — no heuristics to tune (the tuning risk = resolving against a flat index instead of the tree). Two governing rules (operator, 2026-07-06): **no dual representation / minimal at every layer; no ambiguity at any layer.** Rides on `SymbolIndex` = \"the containment tree materialized\" (`type-env-single-authority`, lively-raven lane) — the loyal-heron scaling gate that formerly blocked resolver surgery was **discharged at #6809** (per §7.5 of the design doc: \"the lane is no longer blocked on it\"); `import-scoped` (§1c, today) and `namespace-only-Y` (position-info uniqueness: expected type filters a variant to one, never picks) are two walk-rules over one *fill-agnostic* index (fill = whole tree; policy gates lookup, never fill). Census: 98% of names globally unique → always bare; the residue is v1-seed forks (resolve by subtree, free) + github-style variants ((Y) context-resolves); no consolidate-now forks (the census's 2 same-name flags proved to be a homonym + whole-file v1-seed duplication, not genuine cross-tree §3 forks). Terminal step: delete the `import` grammar + supporting code → `import` becomes a *parse error*, deps derived from `container.member` references (Rule-1 end-state). → [namespace-only resolution design](docs/plans/namespace-resolution-design.md)"), li(text: "**fleet-reconcile spine — one grain-agnostic membership diff (deploy · fleet · session).** ONE `membership_reconcile` reuses `std.change.keyed_two_way_diff` verbatim (no fork): desired set vs observed set keyed by member IDENTITY — Added/Modified → upsert, Removed → teardown-or-refuse, Unchanged → noop (absence of a hunk). A member may be a service/unit/host OR a Session (operator: 'it should all be the same'), so a new member type is a new instantiation of the same fn with its own `(key_of, key_eq, value_eq, ownership_of)` bundle — zero spine change (a Realization, §2; a forked reconcile means the genericity bought nothing). R5 teardown-owned-only is a CONSTRUCTION WALL: a Removed non-owned member yields `MemberTeardownRefused`, which has no effect arm in any apply dispatch, so the bad state is unwritable — never a post-check, never 'assume owned' (the absorbing fallback §5 forbids); ownership-unknown REFUSES too, typed/located/counted. Ownership is extracted as its own single authority (`gunbc.ownership.Ownership`) and `live_deploy`'s `DeploymentStep` re-grounds onto it (pass 2, a declared dissolution trigger — never a second `Owned|Ensured`). Pass 1 (spine + R5 + a synthetic discriminating witness the degenerate apply/retract poles cannot exercise) landed. → [membership-diff reconcile spine design](docs/plans/membership-diff-reconcile-spine-design.md)"), - li(text: "**node/subtree visibility grants — two verbs (`Reference`, `Publish`) in the same grant algebra, not a parallel mechanism.** `Reference` gates whether a projection/reference edge may be formed within one compiled program; `Publish` gates whether source appears in an audience storage realization. Both reuse `Grant`/`admit_effect_go`; `AudienceScopeTree` converges the existing cache visibility scope, while concrete `std.realization.Placement` remains a different redundant-computation axis. One graph has N storage projections: a private repo is not a second graph. Rule 1 makes publication visibility monotone along references; Rule 2 makes privacy downward-closed on containment, nearest ancestor winning. A World projection renders private content absent by default, never a churn/name-leaking stub; commitment stubs are later opt-in. `Reference` keeps today's permissive `LegacyOpen` frontier, while new undeclared `Publish` content defaults to the narrowest root; Stage 0 bulk-stamps today's public corpus before the guard activates. Stage 0 is file-grain/two Git roots/push guard; Stage 1 checks the composed graph; Stage 2 rides the module-storage binding. A public-only guard reports only local-marker/dangling-reference coverage and never impersonates the composed-root closure. The locked extension is a **publication capability product, not a ladder**: source/interface disclosure, artifact delivery, local/remote execution, integrity, reproducibility, existence/churn disclosure, revocation/erase, and placement/trust are independent policy axes. An emitted artifact is not source disclosure but remains inspectable; client-held key means plaintext access, distinct from remote/attested key custody. Locked blobs use AEAD with subject/interface/version/audience/policy context bound as associated data plus confidentiality-and-integrity-preserving per-audience key wraps; wrong key/context/authentication is typed refusal. Per-statement holes remain refused; interface/realization is the cut; size padding and `Secret`/`Pii`→`SecretRef` construction constraints remain. Commitments never promise zero leakage: length, timing, interface shape, wrap count, access pattern, low-entropy guessing, and churn stay in a named threat model. Remote execution and SCM diagnostics obey information-flow/noninterference + explicit declassification; metering prices an oracle but does not secure it, and unauthorized audiences receive only a non-leaking diagnostic projection. Cryptographic erase is conditional on every key/wrap/backup/cache/derivative/recovery path being sanitized and the result validated, never “delete one key.” → [node/subtree visibility grants design](docs/plans/node-subtree-visibility-grants.md)"), + li(text: "**one authorization kernel, several typed request profiles (operator directive 2026-08-01).** `std.access.AccessRequest` carries subject, action, object, typed context, and typed evidence; `AccessPolicy` is the sole policy-to-decision seam, `decision_meet` the sole conjunction, and every denial carries a typed cause at structured request coordinates. Effect, reference, publication admission, disclosure, and sealed execution are exact request products projected into that kernel, never independent allow/deny folds. Principals project external evidence — OIDC issuer/subject and observed POSIX effective principal — rather than being invented internal identities. The provisional gunbc#7541 `Verb::Reference` / `Verb::Publish` / `AudienceScopeTree` carriers dissolved: publication audiences are sets with join/meet, because real audiences can overlap without either containing the other. The Stage-0 publication wall preserves its accepted/refused behavior through `PublicationAdmissionRequest` and the one kernel. The locked extension remains a **capability product, not a ladder**: source/interface disclosure, artifact delivery, local/remote execution, integrity, reproducibility, existence/churn disclosure, revocation/erase, and placement/trust are independent policy axes. → [node/subtree visibility grants design](docs/plans/node-subtree-visibility-grants.md)"), li(text: "**effect grants over namespaces — dissolve `Hermetic | Wet` into (frame × verb × subtree).** The 2-valued `Hermetic | Wet` enum conflates four axes (input closure / replayability, output reach / interference, handler binding, selection eligibility) — a §3 state-space conflation with proto-envelopes already forked (the hand-rolled `workspace_root` containment gate, `std.resources.ResourceHandle`, `AuthScope`, `LiveTreeDisposition`). End shape (§3, cited not minted): an effect target is a position in a containment tree that already exists — filesystem paths, URIs, `/proc`, service-operation paths, and the unifying case **code names themselves** (the containment tree the namespace-resolution lane makes the single naming authority); permission is a grant of (verb × subtree) attached to a `Frame`; admissibility is the namespace prefix relation — the same `⊑` the resolver walks, content-addressing hashes, and termination reads, so effects become the **fourth consumer** of the one containment structure. Dispatch checks the envelope fail-closed (`EffectOutsideGrant`, a typed/located/counted refusal, never a silent widen — §5); *replayable* / *isolated* / `LiveTreeDisposition` become **derived** projections, and `Hermetic | Wet | Record` survive only as named envelope presets, deleted at the end. Frame-containment is graded on the §5 construction/validation axis (`LifecycleByConstruction` vs `LifecycleByConvention`). No code lands from the design doc; the FLAG-A interim is the only near-term consumer. → [effect-namespace-grants design](docs/plans/effect-namespace-grants.md)"), li(text: "**shell → intent: the intent layer is language-blind (operator-aligned 2026-07-17; census/routing consolidated onto sibling authorities 2026-07-19).** §3/§4 restated for the shell case: the `.dag` intent may not name a target language — a workflow is an ordinary dependency graph over modeled operations (§4: a program is `Node`+`Edge`); *the intent IS the `.dag` graph* (operator), and `src/v2/std/orchestration.dag` `Pipeline` is optional sugar, never the authority. Two distinct downstream layers, not to be conflated: **emit** (§4 — `emit(intent, Bash)`, one grammar read backward, target as a *parameter*) and **realization** (§2 — pure-spec → host-effect, N transports `LocalShell`/`SshShell`, `dag/gunbc/host_effect_realize.dag`). **Routing (the load-bearing correction, operator review 2026-07-19): most sites do NOT emit bash.** Runtime-present sites (a gunbc runtime / typed transport is on the path) realize as typed service ops / typed transports / binary-interpreted effect-plan via `host_effect_apply` — no bash emitted (precedent already in tree: `floor_diff_observe` calls `git.Core.DiffUnified0` directly; `host_identity_converge` funnels through `host_effect_apply`); only foreign executors (GHA `run:`, cron, git hooks) and pre-runtime bootstrap windows render through the bounded v2 bash grammar rows. The authority for that routing is `authorize_shell_emission` (provisioning-window lane); the per-site residual census + arc-completion scoping is `shell-to-dag-residual-census-and-arc-completion.md` (witty-ibex-317) — DESIGN carries no parallel copy of either. **Invariant (enforceable):** the intent imports no language-construction vocab — emit-internal; `src/v2/lens/realization_vocabulary_containment.dag` confines it (LANDED #6854). The lens confines by MODULE, and naming what it actually reads is the whole point of §3's cite-the-symbol rule: `target_ast_vocab_module_prefixes` = `v2.extdeps.languages.bash`, `v2.workflow.bash`, plus `target_ast_vocab_modules` = `extdeps.languages.typescript.program`. The bash vocabulary is `v2.extdeps.languages.bash_build`'s `bash_build_*` constructors and the `v2.workflow.bash_command_fold_serialize` backend. *(Corrected 2026-07-31: this sentence previously listed the confined vocab as ``bash_build``, ``bash_command_fold_serialize``, ``the ShellStmt/ShellWord/ShellProgram coproduct``, ``serialize_bash``. The first two are real modules; the last two NAME NOTHING — the bash AST is built as generic `Node` (every `bash_build_*` fn returns `Node`), there is no ShellStmt/ShellWord/ShellProgram coproduct, and no `serialize_bash` is defined anywhere in the tree. Both were dissolved by the typed-WitnessBin migration (Phase D, 2026-07-14), which the `dag/tools/*` transport notes record and this row did not follow. The WALL WAS NEVER AFFECTED — the lens confines module prefixes, not these names, so nothing was unguarded; what rotted was the description of it, in the sentence declaring the invariant. That is the §3 positional/symbolic citation class exactly: a name-level claim in the canonical authority that a grep would have decided, sitting false while the mechanism it describes stayed correct. Found by a corpus citation sweep, not by anyone touching this row.)* Meta-exec confinement is **PARTIAL**: Wave A1 (#6900) module-isolated `shell.Exec.Run/.Check` and the import wall now has an empty exception roster; the still-open construction half is symbol/reference-level visibility that prevents allowed realization-edge modules from forming arbitrary transport values. *(Corrected 2026-07-26: this read \"`host_language_transport_script` lens inert, `fail_closed_lockdown.dag`\". That lens is **LIVE** — activated by #7184 as a per-PR `ReadsLiveTree` consumer that reds a new raw literal at an enrolled `Run` position — and the transport-script hole it was cited for is **closed by construction on the `ShellOnHost` route only**: `ShellOnHost.script` is the `RetainedShellScript` record and the free minter is deleted — while `shell.Exec.Run` itself still declares a transparent `TransportScript` brand over `String`, so a direct `as TransportScript` cast stays writable from any module and that sink is lens-guarded, not construction-walled (corrected 2026-07-27, `review 43600`; closing it is the meta-exec confinement milestone). The lens stays deliberately green on `ComputedApplication`, so it backstops literal blobs rather than being the wall. What remains open is the construction confinement named above, not the already-landed module-import wall.)* **Carriers → `Do\{effect\}` (never a command string, host-effect-orchestration.md):** the anemic `String` leaves are `host_effect.ShellCommand\{script\}`, `std.orchestration.Run.command`, and `host_effect.BootstrapFragment.script` — add-replacement → migrate all consumers → delete at arc close (§5 construction; `host_effect.dag` is load-bearing, the delete escalated). **Phases:** 0 sidecar delete LANDED (#6831) → 1 agnostic `While`/`BoundedPoll`/`Retry` emit PARTIAL (#6832; corrected 2026-07-26, verified against main — `While` framing has exactly one production consumer, `ci_floor_peak_emit`, whose cond and body are still raw shell strings; `BoundedPoll` has zero production construction sites, test-only; #7303 makes Retry refuse multi-step bodies, present redirect/capture, changed template log sources, and three-or-more (N-level) escalation shapes; #7318 additionally refuses every nonempty source `Run.env` while preserving empty-env emission bytes, proven by executing nonempty-refusal and empty-env accepted controls. Phase 1 remains PARTIAL only because the live `While` consumer has two raw command leaves, `BoundedPoll` is test-only, and the modeled scope still needs need-driven adoption or explicit narrowing) → 2 model runtime-present sites as typed effects on `host_effect_apply` + emit the foreign-executor/bootstrap residue → 3 wall green (bash-AST containment + meta-exec confinement). → [shell → intent design](docs/plans/shell-intent-emit-realization-design.md)"), - li(text: "**srvN build-cache provisioning on host-standup subsumption spine.** Legacy `ctrl-sccache.service` is misconfigured; `ci_release_build_script()` absorbing fallback (`CARGO_BUILD_JOBS=1` then `-u RUSTC_WRAPPER`) masks the deficit (§5). Design anchor: provision sccache as a host-effect ensure on the assimilation spine (P1b after `RunnerDeploySlot`), generalizing srv3's `WorkflowEnsureActuatorToolchain` to srvN; `ProvisionBuildCache { catalog_id }` routes through `host_effect_apply`; verdict fold is `ProvisionConverged | ProvisionRefused` only. **PLACEMENT IS A VERDICT AXIS (landed):** sccache executes cache-miss compiles as *children of the server*, so the server's cgroup is the cgroup every compile on the host runs in — a responsive daemon proves nothing about where it runs, and collapsing the two into one `DaemonStatsOk` arm is the §5 state-space conflation that let a lazily auto-spawned server (kept alive by `SCCACHE_IDLE_TIMEOUT=0`) strand itself in a dead slot's cgroup and bound all five live slots' compiles at one slot's cap. `BuildCacheServerPlacement = ServerManagedUnitOwned | ServerRunnerOwned\{slot,lifecycle\} | ServerUnsupervised | ServerPlacementAbsent | ServerPlacementUnknown` gates `ProvisionConverged`; unknown placement refuses rather than assuming owned, and BOTH runner lifecycles refuse (an active slot means birth ordering failed; a retired slot means birth ordering AND retirement both failed). It is derived from an endpoint-owner observation homed with the deployed-instance model in `gunbc.build_cache_instance` (`BuildCacheInstance` = host x consumer class x instance name, distinct from the CATALOG identity `sccache_local_id`), because process name plus cgroup cannot tell a server from the transient clients every compile spawns — the owner is the process holding the LISTEN on the configured endpoint. **LIVE RECEIPT 2026-08-01 (read-only, srv1/srv2/srv3):** NO host has a unit-owned CI cache; the birth defect is fleet-wide. srv1's owner is pid 3975946, 31.2 days old, in RETIRED slot `srv1-10` (inactive+disabled) with `max 229780` / `oom_kill 12` and a live runner's `rustc` resident beside it; srv2's and srv3's owners sit in ACTIVE slots and so self-limit only because ephemeral runners recycle them. srv4 was unreachable and is carried as typed `ReachRefused`, never as absent. A measured correction landed with it: `--show-stats` does NOT auto-start a server (private-endpoint controls, both UDS and TCP, 0 to 0 listeners) — it answers plausibly with NO server in existence, so stats may only ever be supplemental data read after ownership is grounded; a real COMPILE is what spawns a server, inheriting the invoking cgroup. The budget consequence is a first-class `compile_pool` child of the host allocation tree sized by the cited jobserver authority (`jobserver_build_pool_bytes`) — a per-slot memory cap does *not* bound compile RSS — with `CompilePoolPlacement` making the pool's size derive from the declared topology rather than a fudge. STEP 2 (fallback removal) operator-sequenced after T4 live read-back. → [srvN build-cache provisioning design](docs/plans/srvn-buildcache-provisioning-design.md) · [placement step-1 live receipt](docs/plans/build-cache-placement-receipt.md)"), + li(text: "**srvN build-cache provisioning on host-standup subsumption spine.** Legacy `ctrl-sccache.service` is misconfigured; `ci_release_build_script()` absorbing fallback (`CARGO_BUILD_JOBS=1` then `-u RUSTC_WRAPPER`) masks the deficit (§5). Design anchor: provision sccache as a host-effect ensure on the assimilation spine (P1b after `RunnerDeploySlot`), generalizing srv3's `WorkflowEnsureActuatorToolchain` to srvN; `ProvisionBuildCache { catalog_id }` routes through `host_effect_apply`; verdict fold is `ProvisionConverged | ProvisionRefused` only. STEP 2 (fallback removal) operator-sequenced after T4 live read-back. → [srvN build-cache provisioning design](docs/plans/srvn-buildcache-provisioning-design.md)"), li(text: "**srv4 fleet subsumption — manual-gap receipts (2026-07-22).** srv4 was installed and subsumed through the modeled BMC virtual-media path (PR #7027: 5 runners live, rootless Docker + sccache, seeded-media boot-fidelity fixes root-caused live); every step still performed by hand is typed as a modeling gap in the registered plan `gunbc.plans.fleet_subsumption_manual_gaps` (A1 MaterializedSecret lifecycle, A2 reach-secrets ADC, A3 SecretRef liveness, B1 runner-slot provisioning, B2 runner user, B3 fresh-host prereqs, B4 install actuator), each with an acceptance bar and RED; the plan dissolves item-by-item and fully retires when srv5 subsumes with zero hand-run shell. The BMC credential-rotation gap analysis that seeded the lane: → [srv4 BMC onboarding gap](docs/plans/srv4-bmc-onboarding-gap.md)"), li(text: "**gunb.ai site subsumption — hosting + frontend emitted from `.dag` (operator-directed 2026-07-20).** Subsume `gunb-ai/frontend`: the public site's HTML/CSS/JS become emitted, drift-gated artifacts in this repo, published to GitHub Pages at `gunb.ai` (DNS already points at Pages IPs; cutover is Pages-config-only; the frontend repo archives). Register thesis, developed across nine operator passes and carried as a signable checklist + behavioral-intricacy law: *quiet at arm's length; responsive up close; every response true* — simplicity is a D0/D1 property, intricacy a D2/D3 property of BEHAVIOR (coverage × consistency × timing, the Discord re-read), and every intricate thing shown or answered derives from a real repo fact. Phase-0 slices LANDED under `dag/gunbc/site/` (principles → tokens → interaction → mark → constellation → moodboard → theme_transition → starlight/accent_study), proven by execution with RED controls: themes as total role→material assignments (night = warm starlight on indigo, day = indigo ink on paper, one model, two projections; totality/area/contrast witnessed), the glare envelope (bright OR chromatic, never both — the operator-rejected gamer cyan is the negative control and provably absent from emissions), still-until-touched by construction (MotionTrigger has no ambient variant), the universal theme reframe derived from the same BuildRule rows (role-routed colors cross-fade for free on one token clock; an unthemed color is a counted census row; the data-theme watcher pins the flip so the fade cannot race the UA restyle — measured, snap → 12–16-frame blend), and the accent decision grounded in `extdeps.astronomy.stellar_classification` (cited MK/Harvard table; no green stars exist so green enters only as verdigris material; M self-prunes at the envelope; candidates O/A/G/K rendered from identical fact rows). Remaining: checklist row-by-row sign-off, primitives.html at every depth, mark candidates B–D, craft-floor scales, generated-artifact registration, Pages workflow + cutover receipt (discharges the `2-stateless-frontend` milestone-A shape on the public domain). → [site subsumption design](docs/plans/gunb-ai-site-subsumption-design.md)"), li(text: "**hierarchical design-register library — lift `gunbc.site` L1/L2 into `gunbc.design.*` (DOC-ONLY until #6927 merges).** PR #6927 proves the register on gunb.ai; the follow-on extracts site-independent L1 house walls + L2 role-material machinery (lift-not-fork), re-grounds contrast at L0 (WCAG/OKLCH, dissolving the HSL lightness-gap proxy in `theme_contrast_ok`), and stages L4 archetypes + `DataVizArea` behind second-consumer discipline. Phase A fires on #6927 merge with an emission-identical witness. The **palette catalogue** rode ahead additively (operator 2026-07-21): `extdeps.color.srgb` (CSS Color 4 cite; hex = a serialization of `Srgb8`, HSL→sRGB re-homed out of the WCAG module) → `gunbc.design.palette` (approval IS the computed WCAG grade — ordered figure-on-ground pairings total by construction) → `gunbc.design.catalog` (first entry island-pastel incl. the derived `loam` ink), witnessed by execution; its measured receipts (HSL-S misreads pastel tints as neon — ivory C 0.007 refused while `day_canvas` C 0.008 passes; gamer-cyan C 0.088 < mint C 0.142, so the envelope must be a per-area L×C region) are A.2's discriminating witness — see plan §2.2. → [design-register library](docs/plans/design-register-library.md)"), diff --git a/dag/gunbc/doc_graph_roots.dag b/dag/gunbc/doc_graph_roots.dag index 118412d33a9..3248ac8159b 100644 --- a/dag/gunbc/doc_graph_roots.dag +++ b/dag/gunbc/doc_graph_roots.dag @@ -357,6 +357,13 @@ data hand_authored_doc_binds: List = [ additional_works: [], dissolution: HasTrigger { text: "Nat lives only in std.nat (coproduct + Peano ops), v2.std.nat imports rather than redefines it, the 138-entry co-occurrence class is gone, and integer's GroupCompletion cites the unified type — then this per-concept census doc deletes with this row (full criteria in the doc §9)" }, }, + HandAuthoredDocBind { + home: PlanDoc, + slug: "sole-modeled-publisher-design", + primary_work: DeclarationRef { module_path: "tools.publication_publisher", decl_name: "PublicationAuthority", field: WholeDeclaration }, + additional_works: [DeclarationRef { module_path: "tools.publication_push_shadow", decl_name: "shadow_replay_public_pushes", field: WholeDeclaration }], + dissolution: HasTrigger { text: "Stage 2 binds git.PublicationTransport dispatch to PublicationAuthority (live refuse, not shadow-only), shadow_replay_public_pushes becomes a permanent regression control, and the staged migration either registers as a gunbc.plan.Plan row or deletes when enforcement lands on carriers" }, + }, ] fn hand_authored_doc_graph_roots() -> List { diff --git a/dag/gunbc/host_effect_realize.dag b/dag/gunbc/host_effect_realize.dag index 05aa26c75f3..b5c71764697 100644 --- a/dag/gunbc/host_effect_realize.dag +++ b/dag/gunbc/host_effect_realize.dag @@ -81,7 +81,7 @@ import gunbc.srv3_actuate_shell_paths { srv3_bmcweb_login_response_path, srv3_bmcweb_token_path, } -import std.access { AccessDecision, Permit, Deny } +import std.access { AccessDecision, Permit, Deny, access_policy_refusal } import gunbc.ci_deploy_access { DeployAccess, DeployAccessActor, @@ -1797,14 +1797,13 @@ fn deploy_access_check_observed(access: DeployAccess) -> AccessDecision { match deploy_access_observed_actor(access: access) { Present { value: actor } => deploy_access_check(access: access, actor: actor) Absent => - Deny { - reason: Present { - value: concat( + access_policy_refusal( + profile: "deploy-access-observation", subject: "unobserved", action: "execute", object: "host-effect", + reason: concat( deploy_access_refusal_prefix, " — could not observe principal (ReadEffectivePosixPrincipal failed)" - ) - } - } + ), + ) } } @@ -1869,7 +1868,7 @@ fn host_effect_apply_gated( NotConverged { reason: "host_effect_apply: RedfishAction realization is a fail-closed Unimplemented stub in Phase A; neat-boar-71 realizes it over RedfishRest in Phase C. No plausible no-op for a power/boot action (DESIGN section 5).", applied: intent } IncompatibleCell { reason: why } => NotConverged { reason: why, applied: intent } } - Deny { reason: _ } => + Deny { refusal: _ } => NotConverged { reason: deploy_access_decision_reason(d: verdict), applied: HostEffectIntent { @@ -1897,7 +1896,7 @@ fn host_effect_drive_directive(r: Reconciliation Settled NotConverged { reason: why, applied: intent } => match intent.privilege_denial { - Present { value: Deny { reason: _ } } => Refused { reason: why } + Present { value: Deny { refusal: _ } } => Refused { reason: why } Present { value: Permit } => not_converged_directive(drive: host_effect_drive(intent: intent)) Absent => match resolve_host_effect_cell(target: intent.target, effect: intent.effect) { diff --git a/dag/gunbc/host_identity_converge.dag b/dag/gunbc/host_identity_converge.dag index 9da69daccd3..5bef7f97d65 100644 --- a/dag/gunbc/host_identity_converge.dag +++ b/dag/gunbc/host_identity_converge.dag @@ -222,7 +222,7 @@ fn host_identity_unprivileged_refusal_has_no_marker( NotConverged { reason: why, applied: intent } => !string_contains(s: why, pattern: marker) && match intent.privilege_denial { - Present { value: Deny { reason: _ } } => true + Present { value: Deny { refusal: _ } } => true _ => false } Converged { evidence: _, applied: _ } => false diff --git a/dag/gunbc/live_deploy/apply.dag b/dag/gunbc/live_deploy/apply.dag index 5b643cf8e51..2deb2cb8f4d 100644 --- a/dag/gunbc/live_deploy/apply.dag +++ b/dag/gunbc/live_deploy/apply.dag @@ -199,7 +199,7 @@ fn live_deploy_fold( fixture_host: fixture_host ) match gate { - Deny { reason: _ } => + Deny { refusal: _ } => live_deploy_mutation_denied( target: target, effect: effect, @@ -383,7 +383,7 @@ fn live_deploy_access_preflight_for(access: DeployAccess) -> Reconciliation + Deny { refusal: _ } => NotConverged { reason: deploy_access_decision_reason(d: gate), applied: HostEffectIntent { diff --git a/dag/gunbc/live_deploy/readiness.dag b/dag/gunbc/live_deploy/readiness.dag index a6a6f309b4f..7d920e06868 100644 --- a/dag/gunbc/live_deploy/readiness.dag +++ b/dag/gunbc/live_deploy/readiness.dag @@ -189,7 +189,7 @@ fn show_healthz_read_for_deploy( ) -> ShowEffectiveRead { let verdict = host_effect_privilege_verdict(access: access, fixture_actor: Absent) match verdict { - Deny { reason: _ } => + Deny { refusal: _ } => ReadObserved { value: HealthzEffectiveRead { body: "", @@ -269,7 +269,7 @@ fn capture_unit_diagnosis( ) -> String { let verdict = host_effect_privilege_verdict(access: access, fixture_actor: Absent) match verdict { - Deny { reason: _ } => + Deny { refusal: _ } => concat("unit diagnosis capture failed: ", deploy_access_decision_reason(d: verdict)) Permit => unit_status_capture_text( diff --git a/dag/gunbc/principal_projection.dag b/dag/gunbc/principal_projection.dag new file mode 100644 index 00000000000..2e247f0212c --- /dev/null +++ b/dag/gunbc/principal_projection.dag @@ -0,0 +1,52 @@ +module gunbc.principal_projection + +import std.types { NonEmptyStr } +import extdeps.auth.jwt { JwtStringOrUri } +import extdeps.auth.oidc { OidcIdTokenClaims } +import extdeps.access.posix_effective_principal { EffectivePosixPrincipalRead } + +data principal_projection_note: String = "A gunbc principal is a projection of external authentication evidence, never an independently authored identity. OidcProjectedPrincipal reads issuer and subject directly from extdeps.auth.oidc.OidcIdTokenClaims (whose vocabulary is extdeps.auth.jwt.JwtStringOrUri). PosixProjectedPrincipal reads the non-empty observed result of extdeps.access.posix_effective_principal.EffectivePosixPrincipalRead. PrincipalProjection couples each evidence kind to its only matching constructor, and projected_principal derives rather than stores the principal, so contradictory principal/evidence pairs are unrepresentable. Future policy vocabulary must project the named external authorities rather than mint local substitutes: extdeps.access.aws_iam.PolicyDocument, extdeps.access.rbac.RbacPolicy, and extdeps.access.zanzibar.CheckRequest." + +type OidcProjectedPrincipal { + issuer: JwtStringOrUri + subject: JwtStringOrUri +} + +type PosixProjectedPrincipal { + effective_name: NonEmptyStr +} + +type GunbcPrincipal + = OidcPrincipal { principal: OidcProjectedPrincipal } + | PosixPrincipal { principal: PosixProjectedPrincipal } + +type PosixEffectivePrincipalEvidence { + read: EffectivePosixPrincipalRead + observed_name: NonEmptyStr +} + +type PrincipalProjection + = OidcProjection { claims: OidcIdTokenClaims } + | PosixProjection { observation: PosixEffectivePrincipalEvidence } + +fn oidc_principal_projection(claims: OidcIdTokenClaims) -> PrincipalProjection { + OidcProjection { claims: claims } +} + +fn posix_principal_projection( + observation: PosixEffectivePrincipalEvidence, +) -> PrincipalProjection { + PosixProjection { observation: observation } +} + +fn projected_principal(projection: PrincipalProjection) -> GunbcPrincipal { + match projection { + OidcProjection { claims: claims } => OidcPrincipal { principal: OidcProjectedPrincipal { + issuer: claims.issuer, + subject: claims.subject, + } } + PosixProjection { observation: observation } => PosixPrincipal { principal: PosixProjectedPrincipal { + effective_name: observation.observed_name, + } } + } +} diff --git a/dag/gunbc/publication_grant.dag b/dag/gunbc/publication_grant.dag index a8f9344232d..bfca4df0276 100644 --- a/dag/gunbc/publication_grant.dag +++ b/dag/gunbc/publication_grant.dag @@ -1,27 +1,46 @@ module gunbc.publication_grant import std.types { CommitSha, FilePath, List, String } -import std.effect_grant { - Publish, - Envelope, Grant, RealTransport, LifecycleByConstruction, +import std.access { + AccessDecision, AccessPolicy, AccessRequest, + Permit, Deny, AccessRefusal, AccessRefusalLocation, AccessActionNotGranted, } -import std.materialization_ladder { Frame, SharedStateFrame } -import std.visibility_grant { world_audience } +import std.authorization_profile { + AudienceSet, UniversalAudience, + PublicationAdmissionAction, PublicationContext, + PublicationAdmissionRequest, publication_access_request, + audience_subset, +} +import gunbc.principal_projection { GunbcPrincipal } import gunbc.publication_cutover { public_publication_root, public_publication_cutover_sha, } -data publication_grant_note: String = "Stage-0 public-root publication roster (docs/plans/node-subtree-visibility-grants.md section 6, P-B public half). The one baseline row bulk-stamps every path already present at its exact cutover commit Publish World; it does not mint thousands of per-file declarations. Each file first added after that commit needs one explicit PublicFilePublishGrant row. Both baseline and file rows carry the existing std.effect_grant.Envelope, and the placement gate asks std.effect_grant.admit_effect(Publish, world_audience) -- this carrier never owns a second admission rule. PROVISIONAL CARRIER DISPOSITION (operator directive 2026-08-01): the Publish verb and audience-tree request shape consumed here are frozen provisional, not the permanent publication authorization ontology; this placement gate is expected to re-home its request onto a PublicationAdmissionRequest profile over the std.access AccessRequest/AccessPolicy/decision_meet kernel when the kernel/profile grounding pass lands. This roster remains only the declared-placement-policy input and introduces no identity, principal, role, or broader audience model. The public checkout is the only mounted root in this stage. Private-root and composed-graph closure are deliberately absent and arrive at P-C." +data publication_grant_note: String = "Stage-0 public-root publication roster. The one baseline row bulk-stamps every path already present at its exact cutover commit for the universal audience; each file first added after that commit needs one explicit PublicFilePublishGrant row. The gunbc#7541 Envelope + Verb::Publish + AudienceScopeTree carrier is dissolved. A row now carries PublicationAudienceGrant evidence, constructs std.authorization_profile.PublicationAdmissionRequest, projects it to std.access.AccessRequest, and asks public_publication_policy at the one AccessPolicy decision seam. The public checkout remains the only mounted root; this carrier introduces no authored principal or role." + +type PublicPublicationContext { + root: FilePath +} + +type PublicPlacementPolicySubject {} + +type PublicPlacementSource { + root: FilePath +} + +type PublicationAudienceGrant

{ + audience: AudienceSet

+} type PublicRootPublishBaseline { root: FilePath cutover: CommitSha - envelope: Envelope + grant: PublicationAudienceGrant } type PublicFilePublishGrant { path: FilePath - envelope: Envelope + grant: PublicationAudienceGrant } type PublicPublicationGrantRoster { @@ -29,32 +48,70 @@ type PublicPublicationGrantRoster { file_grants: List } -fn publication_frame(name: String) -> Frame { - Frame { name: name, kind: SharedStateFrame } +fn public_audience() -> AudienceSet { + UniversalAudience +} + +fn public_publication_request( + resource: FilePath, + root: FilePath, + grant: PublicationAudienceGrant, +) -> PublicationAdmissionRequest, GunbcPrincipal> { + PublicationAdmissionRequest { + subject: PublicPlacementPolicySubject {}, + source: PublicPlacementSource { root: root }, + resource: resource, + audience: public_audience(), + context: PublicPublicationContext { root: root }, + evidence: grant, + } } -fn publish_world_envelope(frame_name: String) -> Envelope { - Envelope { - frame: publication_frame(name: frame_name), - grants: [Grant { - verb: Publish, - root: world_audience(), - binding: RealTransport, - lifecycle: LifecycleByConstruction, - }], +fn public_publication_decide( + request: AccessRequest, FilePath, PublicationContext, PublicationAudienceGrant>, +) -> AccessDecision { + if audience_subset( + left: request.context.value.audience, + right: request.evidence.value.audience, + ) { + Permit + } else { + Deny { + refusal: AccessRefusal { + cause: AccessActionNotGranted { action: "publication" }, + location: AccessRefusalLocation { + profile: "PublicationAdmissionRequest", + subject: request.object, + action: "publication", + object: request.context.value.context.root, + }, + } + } } } +data public_publication_policy: AccessPolicy, FilePath, PublicationContext, PublicationAudienceGrant> = AccessPolicy { decide: public_publication_decide } + +fn decide_publication_request( + request: PublicationAdmissionRequest, GunbcPrincipal>, +) -> AccessDecision { + public_publication_policy.decide(publication_access_request(request: request)) +} + +fn publish_public_audience_grant() -> PublicationAudienceGrant { + PublicationAudienceGrant { audience: public_audience() } +} + data public_root_publish_baseline: PublicRootPublishBaseline = PublicRootPublishBaseline { root: public_publication_root, cutover: public_publication_cutover_sha, - envelope: publish_world_envelope(frame_name: "public-root-cutover"), + grant: publish_public_audience_grant(), } fn public_file_publish_grant(path: FilePath) -> PublicFilePublishGrant { PublicFilePublishGrant { path: path, - envelope: publish_world_envelope(frame_name: path), + grant: publish_public_audience_grant(), } } @@ -72,15 +129,23 @@ data public_file_publish_grants: List = [ public_file_publish_grant(path: "dag/extdeps/vendor/salesforce.dag"), public_file_publish_grant(path: "dag/gunbc/econ/acquisition.dag"), public_file_publish_grant(path: "dag/gunbc/heal_revalidation.dag"), + public_file_publish_grant(path: "dag/extdeps/git/publication_transport.dag"), + public_file_publish_grant(path: "dag/gunbc/principal_projection.dag"), public_file_publish_grant(path: "dag/gunbc/publication_cutover.dag"), public_file_publish_grant(path: "dag/gunbc/publication_grant.dag"), public_file_publish_grant(path: "dag/gunbc/roadmap_presentation.dag"), public_file_publish_grant(path: "dag/gunbc/source_integration_proof_kernel.dag"), + public_file_publish_grant(path: "dag/std/access.dag"), + public_file_publish_grant(path: "dag/std/authorization_profile.dag"), + public_file_publish_grant(path: "dag/tools/publication_publisher.dag"), + public_file_publish_grant(path: "dag/tools/publication_push_shadow.dag"), + public_file_publish_grant(path: "dag/test/claim/access_authorization_profile_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/acquisition_mechanism_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/advertising_interface_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/crm_stage_vocabulary_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/heal_revalidation_integration_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/heal_revalidation_witness_test.dag"), + public_file_publish_grant(path: "dag/test/claim/principal_projection_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/roadmap_presentation_witness_test.dag"), public_file_publish_grant(path: "dag/test/claim/source_integration_proof_kernel_acceptance_test.dag"), public_file_publish_grant(path: "dag/test/claim/source_integration_proof_kernel_model_witness_test.dag"), @@ -88,10 +153,10 @@ data public_file_publish_grants: List = [ public_file_publish_grant(path: "dag/test/claim/workflow_dispatch_input_witness_test.dag"), public_file_publish_grant(path: "dag/tools/ci_heal_dispatch.dag"), public_file_publish_grant(path: "dag/tools/publication_placement_gate.dag"), - public_file_publish_grant(path: "docs/plans/roadmap-presentation-seam-design.md"), - public_file_publish_grant(path: "src/v2/workflow/ci_heal_revalidation_preflight_emit.dag"), - public_file_publish_grant(path: "src/v2/workflow/ci_heal_revalidation_preflight_emit_test.dag"), public_file_publish_grant(path: "dag/test/claim/publication_placement_gate_test.dag"), + public_file_publish_grant(path: "dag/test/claim/publication_publisher_witness_test.dag"), + public_file_publish_grant(path: "docs/plans/roadmap-presentation-seam-design.md"), + public_file_publish_grant(path: "docs/plans/sole-modeled-publisher-design.md"), public_file_publish_grant(path: "src/v2/test/fixture/walk_plan_stage/recursion_refusal_member_test.dag"), public_file_publish_grant(path: "dag/gunbc/v1_interpreter_primitive_surface.dag"), public_file_publish_grant(path: "dag/test/claim/v1_interpreter_primitive_surface_witness_test.dag"), @@ -100,6 +165,8 @@ data public_file_publish_grants: List = [ public_file_publish_grant(path: "docs/plans/build-cache-placement-receipt.md"), public_file_publish_grant(path: "dag/extdeps/languages/lean/overflow.dag"), public_file_publish_grant(path: "src/v2/test/claim/lean_overflow_semantics_witness_test.dag"), + public_file_publish_grant(path: "src/v2/workflow/ci_heal_revalidation_preflight_emit.dag"), + public_file_publish_grant(path: "src/v2/workflow/ci_heal_revalidation_preflight_emit_test.dag"), ] data post_cutover_race_repair_note: String = "Repair row block (2026-08-01): these eleven paths (eight CI-heal/workflow paths, three source-integration proof-kernel paths from PR 7563) merged to main from branches cut BEFORE the placement gate landed (#7560), so main itself carried post-cutover Added paths with no publication decision and the gate refused every subsequent PR merge ref. Each is public CI/heal/workflow machinery; Publish World is the deliberate decision for all eleven. The race class dissolves when the placement gate becomes a required branch-protection check: a merge ref must then stamp its own added files before merging, so main can never re-enter this state." diff --git a/dag/gunbc/srv3_host_effect_apply.dag b/dag/gunbc/srv3_host_effect_apply.dag index c8f65b16a3a..987b9dce06c 100644 --- a/dag/gunbc/srv3_host_effect_apply.dag +++ b/dag/gunbc/srv3_host_effect_apply.dag @@ -16,7 +16,7 @@ fn srv3_seeded_install_media_toolchain_unprivileged_fixture_denied() -> Bool { access: srv3_seeded_install_media_toolchain_ensure_deploy_access, actor: deploy_access_unprivileged_fixture_actor(), ) { - Deny { reason: _ } => true + Deny { refusal: _ } => true Permit => false } } diff --git a/dag/std/access.dag b/dag/std/access.dag index f5ab7b44628..2cb5df54bc6 100644 --- a/dag/std/access.dag +++ b/dag/std/access.dag @@ -1,22 +1,89 @@ module std.access +import std.types { String } + +data access_kernel_note: String = "The one authorization decision kernel. Domain-specific authorization surfaces are typed request profiles projected into AccessRequest; they do not mint a second admitted/refused sum. Context and evidence stay distinct because ambient circumstances are not proof, and proof is not the circumstance it supports. A denial always carries a typed cause at structured request coordinates: the former optional reason made an unlocated bare deny writable. AccessPolicy is the sole policy-to-decision seam and decision_meet is the sole conjunction over decisions." + +type AccessContext { + value: C +} + +type AccessEvidence { + value: E +} + +type AccessRequest { + subject: S + action: A + object: O + context: AccessContext + evidence: AccessEvidence +} + +type AccessRefusalCause + = AccessContextAbsent { context: String } + | AccessEvidenceAbsent { evidence: String } + | AccessEvidenceRejected { evidence: String, reason: String } + | AccessSubjectNotRecognized { subject: String } + | AccessActionNotGranted { action: String } + | AccessObjectNotGranted { object: String } + | AccessPolicyRefused { reason: String } + +type AccessRefusalLocation { + profile: String + subject: String + action: String + object: String +} + +type AccessRefusal { + cause: AccessRefusalCause + location: AccessRefusalLocation +} + type AccessDecision = Permit - | Deny { reason: String? } + | Deny { refusal: AccessRefusal } + +fn access_policy_refusal( + profile: String, + subject: String, + action: String, + object: String, + reason: String, +) -> AccessDecision { + Deny { + refusal: AccessRefusal { + cause: AccessPolicyRefused { reason: reason }, + location: AccessRefusalLocation { + profile: profile, + subject: subject, + action: action, + object: object, + }, + } + } +} + +fn access_refusal_reason(refusal: AccessRefusal) -> String { + match refusal.cause { + AccessContextAbsent { context: context } => context + AccessEvidenceAbsent { evidence: evidence } => evidence + AccessEvidenceRejected { evidence: evidence, reason: reason } => reason + AccessSubjectNotRecognized { subject: subject } => subject + AccessActionNotGranted { action: action } => action + AccessObjectNotGranted { object: object } => object + AccessPolicyRefused { reason: reason } => reason + } +} fn decision_meet(a: AccessDecision, b: AccessDecision) -> AccessDecision { match a { - Deny { reason: r } => Deny { reason: r } + Deny { refusal: refusal } => Deny { refusal: refusal } Permit => b } } -type AccessRequest { - subject: S - action: A - object: O -} - -type AccessPolicy { - decide: fn(AccessRequest) -> AccessDecision +type AccessPolicy { + decide: fn(AccessRequest) -> AccessDecision } diff --git a/dag/std/authorization_profile.dag b/dag/std/authorization_profile.dag new file mode 100644 index 00000000000..e206264dec4 --- /dev/null +++ b/dag/std/authorization_profile.dag @@ -0,0 +1,198 @@ +module std.authorization_profile + +import std.types { Bool, List, Set, String } +import std.access { AccessRequest, AccessContext, AccessEvidence } + +data authorization_profile_note: String = "Typed authorization request profiles over the single std.access kernel. A profile is an exact product and a projection to AccessRequest; none declares an admitted/refused carrier or an allow/deny fold. EffectRequest carries an actor, effect, target, ambient context, and evidence. ReferenceRequest carries the referrer and referenced object separately. PublicationAdmissionRequest asks whether one resource may inhabit an audience set. DisclosureRequest adds the disclosed field set. SealedExecutionRequest keeps the sealed resource, operation, and execution context distinct." + +type AudienceSet

+ = UniversalAudience + | EnumeratedAudience { members: Set

} + +data audience_set_note: String = "An audience is a set, not the frozen World > Network > Org > Repo chain from gunbc#7541. Enumerated audiences use the canonical std.types.Set carrier, so duplicate membership is unrepresentable; overlapping sets need not contain one another. UniversalAudience is top and the empty EnumeratedAudience is bottom. audience_subset derives the partial order from canonical set_union, which is also the audience_join operation." + +fn audience_member

( + audience: AudienceSet

, + principal: P, +) -> Bool { + match audience { + UniversalAudience => true + EnumeratedAudience { members: members } => + set_contains(members, principal) + } +} + +fn audience_subset

( + left: AudienceSet

, + right: AudienceSet

, +) -> Bool { + match left { + UniversalAudience => match right { + UniversalAudience => true + EnumeratedAudience { members: members } => false + } + EnumeratedAudience { members: members } => match right { + UniversalAudience => true + EnumeratedAudience { members: right_members } => + set_union(members, right_members) == right_members + } + } +} + +fn audience_join

( + left: AudienceSet

, + right: AudienceSet

, +) -> AudienceSet

{ + match left { + UniversalAudience => UniversalAudience + EnumeratedAudience { members: left_members } => match right { + UniversalAudience => UniversalAudience + EnumeratedAudience { members: right_members } => EnumeratedAudience { + members: set_union(left_members, right_members) + } + } + } +} + +type EffectAuthorizationAction { + effect: A +} + +type EffectRequest { + subject: S + effect: A + target: O + context: C + evidence: E +} + +fn effect_access_request( + request: EffectRequest, +) -> AccessRequest, O, C, E> { + AccessRequest { + subject: request.subject, + action: EffectAuthorizationAction { effect: request.effect }, + object: request.target, + context: AccessContext { value: request.context }, + evidence: AccessEvidence { value: request.evidence }, + } +} + +type ReferenceAction {} + +type ReferenceContext { + referrer: R + context: C +} + +type ReferenceRequest { + referrer: R + referenced: R + context: C + evidence: E +} + +fn reference_access_request( + request: ReferenceRequest, +) -> AccessRequest, E> { + AccessRequest { + subject: request.referrer, + action: ReferenceAction {}, + object: request.referenced, + context: AccessContext { value: ReferenceContext { + referrer: request.referrer, + context: request.context, + } }, + evidence: AccessEvidence { value: request.evidence }, + } +} + +type PublicationAdmissionAction { + source: S +} + +type PublicationContext { + audience: AudienceSet

+ context: C +} + +type PublicationAdmissionRequest { + subject: S + source: Source + resource: R + audience: AudienceSet

+ context: C + evidence: E +} + +fn publication_access_request( + request: PublicationAdmissionRequest, +) -> AccessRequest, R, PublicationContext, E> { + AccessRequest { + subject: request.subject, + action: PublicationAdmissionAction { source: request.source }, + object: request.resource, + context: AccessContext { value: PublicationContext { + audience: request.audience, + context: request.context, + } }, + evidence: AccessEvidence { value: request.evidence }, + } +} + +type DisclosureAction { + fields: List +} + +type DisclosureContext { + audience: AudienceSet

+ context: C +} + +type DisclosureRequest { + principal: P + resource: R + fields: List + audience: AudienceSet

+ context: C + evidence: E +} + +fn disclosure_access_request( + request: DisclosureRequest, +) -> AccessRequest, R, DisclosureContext, E> { + AccessRequest { + subject: request.principal, + action: DisclosureAction { fields: request.fields }, + object: request.resource, + context: AccessContext { value: DisclosureContext { + audience: request.audience, + context: request.context, + } }, + evidence: AccessEvidence { value: request.evidence }, + } +} + +type SealedExecutionAction { + operation: O +} + +type SealedExecutionRequest { + principal: P + operation: O + sealed_resource: R + execution_context: C + evidence: E +} + +fn sealed_execution_access_request( + request: SealedExecutionRequest, +) -> AccessRequest, R, C, E> { + AccessRequest { + subject: request.principal, + action: SealedExecutionAction { operation: request.operation }, + object: request.sealed_resource, + context: AccessContext { value: request.execution_context }, + evidence: AccessEvidence { value: request.evidence }, + } +} diff --git a/dag/std/effect_grant.dag b/dag/std/effect_grant.dag index 6be061c0067..dfc4f58f530 100644 --- a/dag/std/effect_grant.dag +++ b/dag/std/effect_grant.dag @@ -6,9 +6,9 @@ import std.materialization_ladder { Frame } data effect_grant_note: String = "P-A model for effect-namespace grants (docs/plans/effect-namespace-grants.md, owned silent-ibex-417). An effect target is a POSITION in a containment tree that already exists; permission is a GRANT of (verb x subtree) attached to a Frame; admissibility is the namespace prefix relation (target under a granted root). This is the reach layer standing ABOVE the single std.effects.EffectShape authority, never a fork of it. admit_effect now GATES real dispatch at the landed P-B enforcement seams (the emit-host build transport, the effect-io host write, the artifact-store scratch store, and the cli_run repo gate). Grants reuse std.materialization_ladder.Frame (the scope/nesting authority) rather than mint a scope noun. DERIVED PROJECTIONS LANDED in std.effect_axes (P-C). Provisional operator defaults carried inline (Q1 verb set, Q2 target realization) pending confirmation -- each with a dissolve-on." -type Verb = Read | Write | Execute | Reference | Publish +type Verb = Read | Write | Execute -data verb_set_note: String = "Q1 AMENDED (dissolve-on fired 2026-07-19, deploy-preflight sudo-probe displaced cost): the effect-derived verbs are Read/Write/Execute, each coarsened from the ONE std.effects.EffectShape authority via verb_of_effect_shape below -- never a parallel effect vocabulary (DESIGN 3). Reference and Publish are visibility extensions: they name graph-reference and publication reach and are not projections of EffectShape. Execute arrives as ExecuteEffect (invoke-nature shape in std.effects, not REST-derived). Create remains deferred until a displaced cost names it. dissolve-on = none (landed authority; operator may amend verb set)." +data verb_set_note: String = "Q1 AMENDED (dissolve-on fired 2026-07-19, deploy-preflight sudo-probe displaced cost): the effect-derived verbs are Read/Write/Execute, each coarsened from the ONE std.effects.EffectShape authority via verb_of_effect_shape below -- never a parallel effect vocabulary (DESIGN 3). Execute arrives as ExecuteEffect (invoke-nature shape in std.effects, not REST-derived). Create remains deferred until a displaced cost names it. The frozen Reference and Publish additions from gunbc#7541 dissolved into std.authorization_profile.ReferenceRequest and PublicationAdmissionRequest: neither is an EffectShape. dissolve-on = none (landed authority; operator may amend verb set)." data execute_verb_note: String = "Execute grant reach for process/binary invocation: target is a NamespacePosition in FilesystemPathTree naming the executable. The verb is shape-grounded: verb_of_effect_shape(ExecuteEffect) = Execute. Deploy-preflight sudo probes materialize via PosixSudoGrant -> Grant projection (gunbc.fleet_posix_accounts). WHO may execute stays GroundedPosixPrincipal/std.access. dissolve-on = deploy-preflight envelope admits via admit_effect (shell-intent Wave A follow-on)." @@ -29,9 +29,8 @@ type NamespaceTree | ProcTree | ServiceOpTree { service: String } | CodeNameTree - | AudienceScopeTree -data namespace_tree_note: String = "Q2 (PROVISIONAL, pending operator ruling): grant targets are positions in CITED trees (DESIGN 3, not minted): the OS filesystem namespace (extdeps/filesystem), URIs (extdeps/uri), /proc/self introspection, REST/Redfish service-operation hierarchies (ServiceOpTree per service), the code-name containment tree the namespace-resolution lane makes the single naming authority (CodeNameTree), and the publication audience hierarchy (AudienceScopeTree). INTERIM realization: path-string prefixes (position_under below). CONVERGENCE -- the anti-fork commitment: NamespacePosition and its prefix relation become PROJECTIONS of the ONE containment-tree walk when SymbolIndex lands (the same relation the resolver walks, content-addressing hashes, termination reads). dissolve-on = SymbolIndex-backed position supersedes the path-string interim." +data namespace_tree_note: String = "Q2 (PROVISIONAL, pending operator ruling): grant targets are positions in CITED trees (DESIGN 3, not minted): the OS filesystem namespace (extdeps/filesystem), URIs (extdeps/uri), /proc/self introspection, REST/Redfish service-operation hierarchies (ServiceOpTree per service), and the code-name containment tree the namespace-resolution lane makes the single naming authority (CodeNameTree). INTERIM realization: path-string prefixes (position_under below). Publication audiences are sets in std.authorization_profile, not positions in this tree; the frozen AudienceScopeTree from gunbc#7541 dissolved. CONVERGENCE -- the anti-fork commitment: NamespacePosition and its prefix relation become PROJECTIONS of the ONE containment-tree walk when SymbolIndex lands (the same relation the resolver walks, content-addressing hashes, termination reads). dissolve-on = SymbolIndex-backed position supersedes the path-string interim." type NamespacePosition { tree: NamespaceTree @@ -45,7 +44,6 @@ fn namespace_tree_eq(a: NamespaceTree, b: NamespaceTree) -> Bool { ProcTree => match b { ProcTree => true, _ => false } ServiceOpTree { service: sa } => match b { ServiceOpTree { service: sb } => sa == sb, _ => false } CodeNameTree => match b { CodeNameTree => true, _ => false } - AudienceScopeTree => match b { AudienceScopeTree => true, _ => false } } } @@ -89,11 +87,9 @@ type Envelope { fn verb_eq(a: Verb, b: Verb) -> Bool { match a { - Read => match b { Read => true, Write => false, Execute => false, Reference => false, Publish => false } - Write => match b { Write => true, Read => false, Execute => false, Reference => false, Publish => false } - Execute => match b { Execute => true, Read => false, Write => false, Reference => false, Publish => false } - Reference => match b { Reference => true, Read => false, Write => false, Execute => false, Publish => false } - Publish => match b { Publish => true, Read => false, Write => false, Execute => false, Reference => false } + Read => match b { Read => true, Write => false, Execute => false } + Write => match b { Write => true, Read => false, Execute => false } + Execute => match b { Execute => true, Read => false, Write => false } } } diff --git a/dag/std/visibility_grant.dag b/dag/std/visibility_grant.dag deleted file mode 100644 index a8867ef6f52..00000000000 --- a/dag/std/visibility_grant.dag +++ /dev/null @@ -1,175 +0,0 @@ -module std.visibility_grant - -import std.types { Bool, String, List } -import std.effect_grant { - Verb, Reference, Publish, - NamespacePosition, AudienceScopeTree, - Grant, grant_coverage, grant_coverage_fold, position_under, -} - -data visibility_grant_note: String = "P-A model for node/subtree visibility grants (docs/plans/node-subtree-visibility-grants.md, section 6). Reference and Publish are two readings of the existing (verb x subtree) Grant algebra, never a parallel permission mechanism. This module is PURE and changes no resolver, storage, or publication behavior. Reference asks whether a code-name edge may be formed. Publish asks whether source may inhabit an audience projection. Audience breadth is encoded as one fixed containment tree so the existing position_under/grant_covers fold remains the comparison authority. P-B supplies the first file-grain storage consumer; P-C later checks the composed graph." - -fn world_audience() -> NamespacePosition { - NamespacePosition { tree: AudienceScopeTree, path: [] } -} - -fn network_audience() -> NamespacePosition { - NamespacePosition { tree: AudienceScopeTree, path: ["network"] } -} - -fn org_audience() -> NamespacePosition { - NamespacePosition { tree: AudienceScopeTree, path: ["network", "org"] } -} - -fn repo_audience() -> NamespacePosition { - NamespacePosition { tree: AudienceScopeTree, path: ["network", "org", "repo"] } -} - -type VisibilityStatus - = LegacyOpen - | Declared { grants: List } - -type NodeVisibility { - node: NamespacePosition - status: VisibilityStatus -} - -fn declaring_container(node: NamespacePosition) -> NamespacePosition { - NamespacePosition { - tree: node.tree, - path: if node.path.count() == 0 { - [] - } else { - node.path.take(n: node.path.count() - 1) - } - } -} - -type ReferenceAdmission - = ReferenceAdmitted - | NameNotVisible { node: NamespacePosition, referrer: NamespacePosition } - -fn visible_from(visibility: NodeVisibility, referrer: NamespacePosition) -> ReferenceAdmission { - match visibility.status { - LegacyOpen => ReferenceAdmitted - Declared { grants: grants } => { - let reference_granted = grant_coverage_fold( - coverage: grant_coverage(grants: grants, verb: Reference, target: referrer), - on_covered: fn(grant) { true }, - on_not_covered: fn(v, t) { false }, - ) - if position_under(target: referrer, root: declaring_container(node: visibility.node)) - || reference_granted { - ReferenceAdmitted - } else { - NameNotVisible { node: visibility.node, referrer: referrer } - } - } - } -} - -type PublishAdmission - = PublishAdmitted { node: NamespacePosition, audience: NamespacePosition } - | PublishAudienceNotGranted { node: NamespacePosition, audience: NamespacePosition } - -fn publish_to(visibility: NodeVisibility, audience: NamespacePosition) -> PublishAdmission { - match visibility.status { - LegacyOpen => - if position_under(target: audience, root: repo_audience()) { - PublishAdmitted { node: visibility.node, audience: audience } - } else { - PublishAudienceNotGranted { node: visibility.node, audience: audience } - } - Declared { grants: grants } => { - let publish_granted = grant_coverage_fold( - coverage: grant_coverage(grants: grants, verb: Publish, target: audience), - on_covered: fn(grant) { true }, - on_not_covered: fn(v, t) { false }, - ) - if publish_granted { - PublishAdmitted { node: visibility.node, audience: audience } - } else { - PublishAudienceNotGranted { node: visibility.node, audience: audience } - } - } - } -} - -fn publish_admission_fold( - admission: PublishAdmission, - on_admitted: fn(NamespacePosition, NamespacePosition) -> T, - on_not_granted: fn(NamespacePosition, NamespacePosition) -> T, -) -> T { - match admission { - PublishAdmitted { node: node, audience: audience } => on_admitted(node, audience) - PublishAudienceNotGranted { node: node, audience: audience } => on_not_granted(node, audience) - } -} - -type PublishAncestorVerdict - = PublishAncestorCompatible - | PublishAncestorNarrower { - ancestor: NamespacePosition, - child: NamespacePosition, - audience: NamespacePosition, - } - -fn publish_ancestor_rule( - ancestor: NodeVisibility, - child: NodeVisibility, - audience: NamespacePosition, -) -> PublishAncestorVerdict { - let child_admitted = publish_admission_fold( - admission: publish_to(visibility: child, audience: audience), - on_admitted: fn(node, admitted_audience) { true }, - on_not_granted: fn(node, denied_audience) { false }, - ) - let ancestor_admitted = publish_admission_fold( - admission: publish_to(visibility: ancestor, audience: audience), - on_admitted: fn(node, admitted_audience) { true }, - on_not_granted: fn(node, denied_audience) { false }, - ) - if child_admitted && !ancestor_admitted { - PublishAncestorNarrower { - ancestor: ancestor.node, - child: child.node, - audience: audience, - } - } else { - PublishAncestorCompatible - } -} - -type PublishClosureVerdict - = PublishClosureClosed - | PublishClosureGap { - referrer: NamespacePosition, - referenced: NamespacePosition, - audience: NamespacePosition, - } - -fn publish_closure_rule( - referrer: NodeVisibility, - referenced: NodeVisibility, - audience: NamespacePosition, -) -> PublishClosureVerdict { - let referrer_admitted = publish_admission_fold( - admission: publish_to(visibility: referrer, audience: audience), - on_admitted: fn(node, admitted_audience) { true }, - on_not_granted: fn(node, denied_audience) { false }, - ) - let referenced_admitted = publish_admission_fold( - admission: publish_to(visibility: referenced, audience: audience), - on_admitted: fn(node, admitted_audience) { true }, - on_not_granted: fn(node, denied_audience) { false }, - ) - if referrer_admitted && !referenced_admitted { - PublishClosureGap { - referrer: referrer.node, - referenced: referenced.node, - audience: audience, - } - } else { - PublishClosureClosed - } -} diff --git a/dag/test/claim/access_authorization_profile_witness_test.dag b/dag/test/claim/access_authorization_profile_witness_test.dag new file mode 100644 index 00000000000..7e0dd4bf9df --- /dev/null +++ b/dag/test/claim/access_authorization_profile_witness_test.dag @@ -0,0 +1,81 @@ +module test.claim.access_authorization_profile_witness_test + +import std.types { Bool, Set, String } +import std.access { + AccessDecision, Permit, Deny, + access_policy_refusal, decision_meet, +} +import std.authorization_profile { + AudienceSet, UniversalAudience, EnumeratedAudience, + audience_subset, audience_join, + EffectRequest, effect_access_request, + ReferenceRequest, reference_access_request, + PublicationAdmissionRequest, publication_access_request, + DisclosureRequest, disclosure_access_request, + SealedExecutionRequest, sealed_execution_access_request, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +fn string_set(a: String, b: String) -> Set { + set_insert(set_insert(empty_set(), a), b) +} + +test fn overlapping_audiences_are_not_forced_into_a_chain() -> Bool { + let left = EnumeratedAudience { members: string_set(a: "alice", b: "bob") } + let right = EnumeratedAudience { members: string_set(a: "bob", b: "carol") } + !audience_subset(left: left, right: right) + && !audience_subset(left: right, right: left) + && match audience_join(left: left, right: right) { + EnumeratedAudience { members: members } => + members == set_insert(string_set(a: "alice", b: "bob"), "carol") + UniversalAudience => false + } +} + +test fn every_profile_projects_context_and_evidence_into_the_kernel() -> Bool { + let effect = effect_access_request(request: EffectRequest { + subject: "actor", effect: "write", target: "file", context: "frame", evidence: "grant", + }) + let reference = reference_access_request(request: ReferenceRequest { + referrer: "caller", referenced: "callee", context: "module", evidence: "visibility-proof", + }) + let publication = publication_access_request(request: PublicationAdmissionRequest { + subject: "session", source: "head-sha", resource: "public-root", + audience: UniversalAudience, context: "cutover", evidence: "coverage-receipt", + }) + let disclosure = disclosure_access_request(request: DisclosureRequest { + principal: "principal", resource: "record", fields: ["email"], + audience: EnumeratedAudience { members: set_insert(empty_set(), "principal") }, + context: "purpose", evidence: "consent", + }) + let sealed = sealed_execution_access_request(request: SealedExecutionRequest { + principal: "principal", operation: "evaluate", sealed_resource: "sealed-model", + execution_context: "enclave", evidence: "attestation", + }) + effect.subject == "actor" && effect.context.value == "frame" && effect.evidence.value == "grant" + && reference.subject == "caller" && reference.object == "callee" + && reference.context.value.referrer == "caller" && reference.evidence.value == "visibility-proof" + && publication.subject == "session" && publication.action.source == "head-sha" + && publication.object == "public-root" && publication.evidence.value == "coverage-receipt" + && disclosure.action.fields == ["email"] && disclosure.context.value.context == "purpose" + && sealed.action.operation == "evaluate" && sealed.context.value == "enclave" +} + +test fn denial_is_typed_located_and_left_biased_under_meet() -> Bool { + let first = access_policy_refusal( + profile: "PublicationAdmissionRequest", subject: "session-a", action: "publication", + object: "public-root", reason: "evidence rejected", + ) + let second = access_policy_refusal( + profile: "SealedExecutionRequest", subject: "session-a", action: "execute", + object: "sealed-model", reason: "attestation absent", + ) + match decision_meet(a: first, b: second) { + Permit => false + Deny { refusal: refusal } => + refusal.location.profile == "PublicationAdmissionRequest" + && refusal.location.object == "public-root" + } +} diff --git a/dag/test/claim/access_iam_validation_test.dag b/dag/test/claim/access_iam_validation_test.dag index c5b0f1fc8c5..0d889ff1847 100644 --- a/dag/test/claim/access_iam_validation_test.dag +++ b/dag/test/claim/access_iam_validation_test.dag @@ -1,16 +1,23 @@ module test.claim.access_iam_validation +import std.types { Bool } +import std.access +import product.network_topology +import v2.std.live_tree data live_tree_disposition: v2.std.live_tree.LiveTreeDisposition = v2.std.live_tree.SubstrateInputsOnly fn decision_is_permit(d: std.access.AccessDecision) -> Bool { - match d { Permit => true std.access.Deny { reason: _ } => false } + match d { Permit => true std.access.Deny { refusal: _ } => false } } fn decision_is_deny(d: std.access.AccessDecision) -> Bool { - match d { std.access.Deny { reason: _ } => true Permit => false } + match d { std.access.Deny { refusal: _ } => true Permit => false } } +type IamRequestContext {} +type IamRequestEvidence {} + type IamPrincipal { arn: std.types.NonEmptyStr source_locality: product.network_topology.NetworkLocality @@ -34,48 +41,48 @@ fn locality_is_internal(l: product.network_topology.NetworkLocality) -> Bool { } } -fn iam_decide(request: std.access.AccessRequest) -> std.access.AccessDecision { +fn iam_decide(request: std.access.AccessRequest) -> std.access.AccessDecision { if locality_is_internal(l: request.subject.source_locality) && request.action.prefix == "reports/" && request.subject.mfa_present { std.access.Permit } else { - std.access.Deny { reason: none } + std.access.access_policy_refusal(profile: "iam", subject: request.subject.arn, action: request.action.operation, object: request.object, reason: "IAM condition not satisfied") } } -data iam_access_policy: std.access.AccessPolicy = { +data iam_access_policy: std.access.AccessPolicy = { decide: iam_decide } test fn iam_internal_prefix_mfa_permits() -> Bool { decision_is_permit(d: iam_decide(request: { subject: { arn: "arn:aws:iam::1:user/alice", source_locality: product.network_topology.Lan, mfa_present: true }, - action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, - object: "arn:aws:s3:::bucket/reports/q3.csv" + action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, object: "arn:aws:s3:::bucket/reports/q3.csv", + context: std.access.AccessContext { value: IamRequestContext {} }, evidence: std.access.AccessEvidence { value: IamRequestEvidence {} } })) } test fn iam_external_source_denies() -> Bool { decision_is_deny(d: iam_decide(request: { subject: { arn: "arn:aws:iam::1:user/alice", source_locality: product.network_topology.Internet, mfa_present: true }, - action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, - object: "arn:aws:s3:::bucket/reports/q3.csv" + action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, object: "arn:aws:s3:::bucket/reports/q3.csv", + context: std.access.AccessContext { value: IamRequestContext {} }, evidence: std.access.AccessEvidence { value: IamRequestEvidence {} } })) } test fn iam_wrong_prefix_denies() -> Bool { decision_is_deny(d: iam_decide(request: { subject: { arn: "arn:aws:iam::1:user/alice", source_locality: product.network_topology.Lan, mfa_present: true }, - action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "secrets/" }, - object: "arn:aws:s3:::bucket/secrets/key.pem" + action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "secrets/" }, object: "arn:aws:s3:::bucket/secrets/key.pem", + context: std.access.AccessContext { value: IamRequestContext {} }, evidence: std.access.AccessEvidence { value: IamRequestEvidence {} } })) } test fn iam_no_mfa_denies() -> Bool { decision_is_deny(d: iam_decide(request: { subject: { arn: "arn:aws:iam::1:user/alice", source_locality: product.network_topology.Lan, mfa_present: false }, - action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, - object: "arn:aws:s3:::bucket/reports/q3.csv" + action: { operation: "s3:extdeps.object_storage.GetObject", prefix: "reports/" }, object: "arn:aws:s3:::bucket/reports/q3.csv", + context: std.access.AccessContext { value: IamRequestContext {} }, evidence: std.access.AccessEvidence { value: IamRequestEvidence {} } })) } diff --git a/dag/test/claim/access_validation_test.dag b/dag/test/claim/access_validation_test.dag index 478cb5ebd32..f4ad4bc7515 100644 --- a/dag/test/claim/access_validation_test.dag +++ b/dag/test/claim/access_validation_test.dag @@ -1,16 +1,27 @@ module test.claim.access_validation +import std.types { Bool, Int, NonEmptyStr } +import std.access { + AccessDecision, AccessRequest, AccessPolicy, AccessContext, AccessEvidence, + Permit, Deny, access_policy_refusal, +} +import extdeps.access.posix { PermissionBits, FilePermissions } +import extdeps.access.rbac { Permission, RbacPolicy } +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly fn decision_is_permit(d: AccessDecision) -> Bool { - match d { Permit => true Deny { reason: _ } => false } + match d { Permit => true Deny { refusal: _ } => false } } fn decision_is_deny(d: AccessDecision) -> Bool { - match d { Deny { reason: _ } => true Permit => false } + match d { Deny { refusal: _ } => true Permit => false } } +type TestAccessContext {} +type TestAccessEvidence {} + type PosixSubject { uid: Int gid: Int @@ -41,15 +52,15 @@ fn posix_class_bits(perms: FilePermissions, subject: PosixSubject) -> Permission } } -fn posix_decide(request: AccessRequest) -> AccessDecision { +fn posix_decide(request: AccessRequest) -> AccessDecision { if posix_bit(bits: posix_class_bits(perms: request.object, subject: request.subject), action: request.action) { Permit } else { - std.access.Deny { reason: none } + access_policy_refusal(profile: "posix", subject: to_string(request.subject.uid), action: "mode-bit", object: "file", reason: "permission bit absent") } } -data posix_access_policy: AccessPolicy = { +data posix_access_policy: AccessPolicy = { decide: posix_decide } @@ -67,25 +78,29 @@ data sample_file: FilePermissions = { test fn posix_owner_can_read() -> Bool { decision_is_permit(d: posix_decide(request: { - subject: { uid: 1000, gid: 1000 }, action: PosixRead, object: sample_file + subject: { uid: 1000, gid: 1000 }, action: PosixRead, object: sample_file, + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } test fn posix_owner_cannot_execute() -> Bool { decision_is_deny(d: posix_decide(request: { - subject: { uid: 1000, gid: 1000 }, action: PosixExecute, object: sample_file + subject: { uid: 1000, gid: 1000 }, action: PosixExecute, object: sample_file, + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } test fn posix_group_can_read() -> Bool { decision_is_permit(d: posix_decide(request: { - subject: { uid: 2000, gid: 1000 }, action: PosixRead, object: sample_file + subject: { uid: 2000, gid: 1000 }, action: PosixRead, object: sample_file, + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } test fn posix_other_cannot_read() -> Bool { decision_is_deny(d: posix_decide(request: { - subject: { uid: 2000, gid: 2000 }, action: PosixRead, object: sample_file + subject: { uid: 2000, gid: 2000 }, action: PosixRead, object: sample_file, + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } @@ -106,11 +121,11 @@ fn rbac_user_has_permission(policy: RbacPolicy, user: NonEmptyStr, perm: Permiss }) } -fn rbac_decide(policy: RbacPolicy, request: AccessRequest) -> AccessDecision { +fn rbac_decide(policy: RbacPolicy, request: AccessRequest) -> AccessDecision { if rbac_user_has_permission(policy: policy, user: request.subject, perm: { operation: request.action, object: request.object }) { Permit } else { - std.access.Deny { reason: none } + access_policy_refusal(profile: "rbac", subject: request.subject, action: request.action, object: request.object, reason: "permission assignment absent") } } @@ -130,18 +145,21 @@ data sample_rbac: RbacPolicy = { test fn rbac_editor_can_write() -> Bool { decision_is_permit(d: rbac_decide(policy: sample_rbac, request: { - subject: "alice", action: "write", object: "doc1" + subject: "alice", action: "write", object: "doc1", + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } test fn rbac_viewer_cannot_write() -> Bool { decision_is_deny(d: rbac_decide(policy: sample_rbac, request: { - subject: "bob", action: "write", object: "doc1" + subject: "bob", action: "write", object: "doc1", + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } test fn rbac_viewer_can_read() -> Bool { decision_is_permit(d: rbac_decide(policy: sample_rbac, request: { - subject: "bob", action: "read", object: "doc1" + subject: "bob", action: "read", object: "doc1", + context: AccessContext { value: TestAccessContext {} }, evidence: AccessEvidence { value: TestAccessEvidence {} } })) } diff --git a/dag/test/claim/ci_deploy_observed_wet_test.dag b/dag/test/claim/ci_deploy_observed_wet_test.dag index 2c921815da7..8a3a6749029 100644 --- a/dag/test/claim/ci_deploy_observed_wet_test.dag +++ b/dag/test/claim/ci_deploy_observed_wet_test.dag @@ -19,7 +19,7 @@ fn witness_deploy_access_preflight_is_fail_closed() -> Bool { s: deploy_access_preflight_pass_message(access: ci_deploy_srv1_access), pattern: ci_deploy_observed_runner_principal_name() ) - Deny { reason: _ } => + Deny { refusal: _ } => string_contains(s: deploy_access_decision_reason(d: d), pattern: deploy_access_refusal_prefix) } } diff --git a/dag/test/claim/ci_deploy_witness_test.dag b/dag/test/claim/ci_deploy_witness_test.dag index 82019697d8b..d1712c58144 100644 --- a/dag/test/claim/ci_deploy_witness_test.dag +++ b/dag/test/claim/ci_deploy_witness_test.dag @@ -66,7 +66,7 @@ test fn witness_deploy_access_sudo_miss_surfaces_sudoers_hint() -> Bool { } match deploy_access_check(access: ci_deploy_srv1_access, actor: actor) { Permit => false - Deny { reason: _ } => + Deny { refusal: _ } => let why = deploy_access_sudo_miss_reason(access: ci_deploy_srv1_access) string_contains(s: why, pattern: gunbc_deploy_sudoers_dropin) && string_contains( diff --git a/dag/test/claim/crosstree_probe_test.dag b/dag/test/claim/crosstree_probe_test.dag index a26753da283..0e7c7cf11a1 100644 --- a/dag/test/claim/crosstree_probe_test.dag +++ b/dag/test/claim/crosstree_probe_test.dag @@ -12,7 +12,7 @@ fn probe_v2_tree() -> v2.std.lens_verdict.LensVerdict { } test fn crosstree_dag_ref_resolves() -> Bool { - match probe_dag_tree() { Permit => true Deny { reason: _ } => false } + match probe_dag_tree() { Permit => true Deny { refusal: _ } => false } } test fn crosstree_v2_ref_resolves() -> Bool { diff --git a/dag/test/claim/deploy_access_privilege_witness_test.dag b/dag/test/claim/deploy_access_privilege_witness_test.dag index 0b20808486c..077ab7e7cfc 100644 --- a/dag/test/claim/deploy_access_privilege_witness_test.dag +++ b/dag/test/claim/deploy_access_privilege_witness_test.dag @@ -15,7 +15,7 @@ test fn witness_modeled_unprivileged_fixture_is_denied() -> Bool { ) match d { Permit => false - Deny { reason: _ } => + Deny { refusal: _ } => string_contains( s: deploy_access_decision_reason(d: d), pattern: "principal mismatch" @@ -38,7 +38,7 @@ test fn witness_unprivileged_mutation_refused_before_shell() -> Bool { match intent.privilege_denial { Present { value: d } => match d { - Deny { reason: _ } => + Deny { refusal: _ } => !string_contains(s: deploy_access_decision_reason(d: d), pattern: privilege_mutation_marker) Permit => false } @@ -62,7 +62,7 @@ test fn witness_unprivileged_mutation_directive_is_refused_terminal() -> Bool { match r { NotConverged { reason: _, applied: intent } => match intent.privilege_denial { - Present { value: Deny { reason: _ } } => true + Present { value: Deny { refusal: _ } } => true _ => false } Converged { evidence: _, applied: _ } => false @@ -102,4 +102,3 @@ test fn witness_privileged_fixture_surfaces_stdout() -> Bool { NotConverged { reason: _, applied: _ } => false } } - diff --git a/dag/test/claim/deploy_mutation_gate_witness_test.dag b/dag/test/claim/deploy_mutation_gate_witness_test.dag index d675b53ffb6..2602f141e5a 100644 --- a/dag/test/claim/deploy_mutation_gate_witness_test.dag +++ b/dag/test/claim/deploy_mutation_gate_witness_test.dag @@ -19,14 +19,10 @@ fn srv2_host_observation() -> DeployTargetHostObservation { } test fn witness_decision_meet_preserves_left_denial_reason() -> Bool { - let left = std.access.Deny { reason: Present { value: "host-deny-reason" } } - let right = std.access.Deny { reason: Present { value: "access-deny-reason" } } + let left = std.access.access_policy_refusal(profile: "host", subject: "runner", action: "deploy", object: "host", reason: "host-deny-reason") + let right = std.access.access_policy_refusal(profile: "access", subject: "runner", action: "deploy", object: "host", reason: "access-deny-reason") match decision_meet(a: left, b: right) { - Deny { reason: r } => - match r { - Present { value: why } => why == "host-deny-reason" - Absent => false - } + Deny { refusal: refusal } => std.access.access_refusal_reason(refusal: refusal) == "host-deny-reason" Permit => false } } @@ -38,7 +34,7 @@ test fn witness_wrong_host_unprivileged_both_deny_surfaces_host_reason_only() -> fixture_host: Present { value: srv2_host_observation() } ) match gate { - Deny { reason: _ } => + Deny { refusal: _ } => let why = deploy_access_decision_reason(d: gate) string_contains(s: why, pattern: deploy_target_host_refusal_prefix) && !string_contains(s: why, pattern: deploy_access_refusal_prefix) @@ -57,7 +53,7 @@ test fn witness_wrong_host_unprivileged_both_deny_refused_before_shell() -> Bool ) { NotConverged { reason: why, applied: intent } => match intent.privilege_denial { - Present { value: Deny { reason: _ } } => + Present { value: Deny { refusal: _ } } => string_contains(s: why, pattern: deploy_target_host_refusal_prefix) && !string_contains(s: why, pattern: mutation_gate_marker) _ => false @@ -75,7 +71,7 @@ test fn witness_wrong_host_privileged_gate_denies() -> Bool { fixture_host: Present { value: srv2_host_observation() } ) match gate { - Deny { reason: _ } => + Deny { refusal: _ } => string_contains( s: deploy_access_decision_reason(d: gate), pattern: deploy_target_host_refusal_prefix @@ -90,7 +86,7 @@ test fn witness_right_host_unprivileged_gate_denies() -> Bool { fixture_actor: Present { value: deploy_access_unprivileged_fixture_actor() }, fixture_host: Present { value: srv1_host_observation() } ) { - Deny { reason: _ } => true + Deny { refusal: _ } => true Permit => false } } @@ -104,7 +100,7 @@ test fn witness_right_host_privileged_gate_permits() -> Bool { fixture_host: Present { value: srv1_host_observation() } ) { Permit => true - Deny { reason: _ } => false + Deny { refusal: _ } => false } } @@ -121,7 +117,7 @@ test fn witness_wrong_host_privileged_refused_before_shell() -> Bool { ) { NotConverged { reason: why, applied: intent } => match intent.privilege_denial { - Present { value: Deny { reason: _ } } => + Present { value: Deny { refusal: _ } } => !string_contains(s: why, pattern: mutation_gate_marker) _ => false } @@ -140,7 +136,7 @@ test fn witness_right_host_unprivileged_refused_before_shell() -> Bool { ) { NotConverged { reason: why, applied: intent } => match intent.privilege_denial { - Present { value: Deny { reason: _ } } => + Present { value: Deny { refusal: _ } } => !string_contains(s: why, pattern: mutation_gate_marker) _ => false } @@ -160,4 +156,3 @@ test fn witness_right_host_privileged_may_apply() -> Bool { fixture_host: Present { value: srv1_host_observation() } )) } - diff --git a/dag/test/claim/grounded_principal_witness_test.dag b/dag/test/claim/grounded_principal_witness_test.dag index a43eb322270..6e1819b8f9b 100644 --- a/dag/test/claim/grounded_principal_witness_test.dag +++ b/dag/test/claim/grounded_principal_witness_test.dag @@ -107,7 +107,7 @@ test fn witness_ci_runner_grounded_access_gate_permits() -> Bool { fixture_host: Present { value: srv1_host_observation() } ) { Permit => true - Deny { reason: _ } => false + Deny { refusal: _ } => false } } @@ -120,7 +120,7 @@ test fn witness_operator_grounded_access_gate_permits() -> Bool { fixture_host: Present { value: srv1_host_observation() } ) { Permit => true - Deny { reason: _ } => false + Deny { refusal: _ } => false } } diff --git a/dag/test/claim/principal_projection_witness_test.dag b/dag/test/claim/principal_projection_witness_test.dag new file mode 100644 index 00000000000..2381f9107a9 --- /dev/null +++ b/dag/test/claim/principal_projection_witness_test.dag @@ -0,0 +1,59 @@ +module test.claim.principal_projection_witness_test + +import std.types { Bool, NonEmptyStr } +import extdeps.auth.jwt { + NumericDateWholeSeconds, JwtBareString, JwtUriString, JwtAudienceSingle, +} +import extdeps.auth.oidc { OidcStandardClaims, OidcIdTokenClaims } +import extdeps.access.posix_effective_principal { EffectivePosixPrincipalRead } +import gunbc.principal_projection { + OidcPrincipal, PosixPrincipal, + OidcProjection, PosixProjection, + PosixEffectivePrincipalEvidence, + oidc_principal_projection, posix_principal_projection, projected_principal, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +fn oidc_evidence() -> OidcIdTokenClaims { + OidcIdTokenClaims { + issuer: JwtUriString { value: "https://issuer.example" as NonEmptyStr, scheme: "https" as NonEmptyStr }, + subject: JwtBareString { value: "external-subject" as NonEmptyStr }, + audience: JwtAudienceSingle { value: JwtBareString { value: "client" as NonEmptyStr } }, + expires_at: NumericDateWholeSeconds { seconds: 2000 }, + issued_at: NumericDateWholeSeconds { seconds: 1000 }, + auth_time: none, nonce: none, acr: none, amr: none, azp: none, session_id: none, + standard: OidcStandardClaims { email: none, email_verified: none, name: none }, + } +} + +test fn oidc_principal_is_exact_issuer_subject_projection_with_evidence_retained() -> Bool { + let claims = oidc_evidence() + let projection = oidc_principal_projection(claims: claims) + match projected_principal(projection: projection) { + PosixPrincipal { principal: _ } => false + OidcPrincipal { principal: principal } => + principal.issuer == claims.issuer && principal.subject == claims.subject + && match projection { + OidcProjection { claims: retained } => retained == claims + PosixProjection { observation: _ } => false + } + } +} + +test fn posix_principal_is_exact_effective_name_projection_with_evidence_retained() -> Bool { + let observation = PosixEffectivePrincipalEvidence { + read: EffectivePosixPrincipalRead {}, observed_name: "ghrunner" as NonEmptyStr, + } + let projection = posix_principal_projection(observation: observation) + match projected_principal(projection: projection) { + OidcPrincipal { principal: _ } => false + PosixPrincipal { principal: principal } => + principal.effective_name == observation.observed_name + && match projection { + OidcProjection { claims: _ } => false + PosixProjection { observation: retained } => retained == observation + } + } +} diff --git a/dag/test/claim/publication_placement_gate_test.dag b/dag/test/claim/publication_placement_gate_test.dag index 76f9173ee4c..ad69a48be81 100644 --- a/dag/test/claim/publication_placement_gate_test.dag +++ b/dag/test/claim/publication_placement_gate_test.dag @@ -2,13 +2,13 @@ module test.claim.publication_placement_gate import std.types { Bool, List } import std.process { ProcessExit, ExitSuccess, ExitFailure } -import std.effect_grant { Envelope } -import std.materialization_ladder { Frame, SharedStateFrame } +import std.authorization_profile { EnumeratedAudience } import std.measure { percent } import extdeps.git { GitDiffStatusEntry, DiffCopiedStatus, DiffRenamedStatus, } import gunbc.publication_grant { + PublicationAudienceGrant, PublicRootPublishBaseline, PublicFilePublishGrant, PublicPublicationGrantRoster, public_root_publish_baseline, public_file_publish_grant, @@ -29,7 +29,7 @@ import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly -data publication_placement_acceptance_note: String = "Discriminating P-B acceptance probes over the real gate decision and boundary. Advancing the roster cutover REDs against the independent fixed cutover authority before it can hide later files. The ungranted added path reaches a located AddedPathPublishGrantAbsent and an ExitFailure; adding its roster envelope reaches ExitSuccess. Ungranted copied and renamed destinations RED through the same path, closing Git's alternate encodings for a new public path. RED controls also prove that repeated file markers and marker envelopes refused by std.effect_grant.admit_effect cannot produce an honest coverage receipt. Every probe calls publication_guard_local_check, whose only publication decision is std.effect_grant.admit_effect(Publish, world_audience), then publication_guard_process_exit, the same boundary the live gate runs. Live git.Core.DiffNameStatus execution from the exact cutover to HEAD is enrolled separately as the Wet server-side floor runnable tools.floor_effect_gate_witness.publication_placement_gate_passes; it is not duplicated into the hermetic discovery corpus." +data publication_placement_acceptance_note: String = "Behavior-preservation witnesses over the real Stage-0 gate after dissolving gunbc#7541's provisional effect-grant carrier. The same baseline, missing-marker, repeated-marker, refused-marker, added, copied, and renamed cases reach the same accepted/refused gate outcomes and ProcessExit boundary. The only publication allow/deny now flows PublicationAdmissionRequest -> std.access.AccessRequest -> public_publication_policy; an empty audience grant is the discriminating RED against the universal public audience." data synthetic_added_path: String = "dag/example/new_public_file.dag" @@ -67,10 +67,7 @@ fn refused_marker_roster() -> PublicPublicationGrantRoster { baseline: public_root_publish_baseline, file_grants: [PublicFilePublishGrant { path: synthetic_added_path, - envelope: Envelope { - frame: Frame { name: "synthetic-refused-marker", kind: SharedStateFrame }, - grants: [], - }, + grant: PublicationAudienceGrant { audience: EnumeratedAudience { members: empty_set() } }, }], } } @@ -80,7 +77,7 @@ fn advanced_cutover_roster() -> PublicPublicationGrantRoster { baseline: PublicRootPublishBaseline { root: public_root_publish_baseline.root, cutover: "synthetic-advanced-cutover", - envelope: public_root_publish_baseline.envelope, + grant: public_root_publish_baseline.grant, }, file_grants: [], } @@ -221,8 +218,10 @@ test fn refused_publication_marker_reds_gate() -> Bool { PublishGuardLocalRefused { receipt: receipt, violations: violations } => violations.count() == 1 && match violations.first() { - FilePublishGrantMarkerRefused { path: path, root: root, verb: verb, target: target, frame: frame } => - path == synthetic_added_path && root == "." && frame.name == "synthetic-refused-marker" + FilePublishGrantMarkerRefused { path: path, root: root, refusal: refusal } => + path == synthetic_added_path + && root == "." + && refusal.location.profile == "PublicationAdmissionRequest" _ => false } && !exit_is_success(exit: publication_guard_process_exit(outcome: outcome)) diff --git a/dag/test/claim/publication_publisher_witness_test.dag b/dag/test/claim/publication_publisher_witness_test.dag new file mode 100644 index 00000000000..1bef8701e72 --- /dev/null +++ b/dag/test/claim/publication_publisher_witness_test.dag @@ -0,0 +1,250 @@ +module test.claim.publication_publisher_witness + +import std.types { Bool, String, GitRef, NonEmptyStr } +import extdeps.git.hooks { PrePushStdinRow, zero_sha } +import extdeps.git.publication_transport { + GitRefUpdateSpec, + shape_git_push_argv, + shape_git_commit_argv, + GitPushRequest, + GitCommitRequest, +} +import gunbc.publication_grant { + public_root_publish_baseline, + public_file_publish_grant, + PublicPublicationGrantRoster, +} +import tools.publication_publisher { + ModeledSessionId, + SoleModeledPublisher, + OrdinarySession, + NotTheSolePublisher, + ProjectionNotAdmitted, + PublicWriteAdmitted, + PublicWriteRefused, + sole_modeled_publisher_session, + session_publication_posture, + admit_public_write, + ordinary_session_lacks_public_write_authority, +} +import tools.publication_push_shadow { + ObservedPublicPush, + ShadowPushWouldRefuse, + ShadowPushWouldAdmit, + shadow_replay_public_pushes, + shadow_refusal_count, + shadow_would_refuse_ordinary_session, +} +import tools.publication_placement_gate { + publication_guard_local_check, + PublishGuardLocalAccepted, + PublishGuardLocalRefused, + AddedPathPublishGrantAbsent, +} +import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } + +data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly + +data synthetic_added_path: String = "dag/example/new_public_file.dag" +data synthetic_ordinary_session: ModeledSessionId = "synthetic-ordinary-session" as ModeledSessionId +data synthetic_head: String = "synthetic-head-sha" + +fn synthetic_push() -> PrePushStdinRow { + PrePushStdinRow { + local_ref: "refs/heads/session/synthetic-ordinary-session", + local_sha: synthetic_head, + remote_ref: "refs/heads/session/synthetic-ordinary-session", + remote_sha: zero_sha, + } +} + +fn ungranted_roster() -> PublicPublicationGrantRoster { + PublicPublicationGrantRoster { + baseline: public_root_publish_baseline, + file_grants: [], + } +} + +fn granted_roster() -> PublicPublicationGrantRoster { + PublicPublicationGrantRoster { + baseline: public_root_publish_baseline, + file_grants: [public_file_publish_grant(path: synthetic_added_path)], + } +} + +test fn ordinary_session_lacks_public_write_authority_holds() -> Bool { + ordinary_session_lacks_public_write_authority(session: synthetic_ordinary_session) + && match session_publication_posture(session: synthetic_ordinary_session) { + OrdinarySession { session: session } => session == synthetic_ordinary_session + SoleModeledPublisher { session: _ } => false + } +} + +test fn sole_publisher_posture_is_unique() -> Bool { + match session_publication_posture(session: sole_modeled_publisher_session) { + SoleModeledPublisher { session: session } => session == sole_modeled_publisher_session + OrdinarySession { session: _ } => false + } +} + +test fn git_push_shape_is_modeled_not_raw_argv() -> Bool { + let argv = shape_git_push_argv(request: GitPushRequest { + remote: "origin" as NonEmptyStr, + ref_updates: [GitRefUpdateSpec { + local_ref: "refs/heads/session/example" as GitRef, + remote_ref: "refs/heads/session/example" as GitRef, + }], + }) + argv == ["git", "push", "origin", "refs/heads/session/example:refs/heads/session/example"] +} + +test fn git_commit_shape_is_modeled_not_raw_argv() -> Bool { + let argv = shape_git_commit_argv(request: GitCommitRequest { + message: "modeled commit" as NonEmptyStr, + allow_empty: false, + }) + argv == ["git", "commit", "-m", "modeled commit"] +} + +test fn ordinary_session_reds_before_projection_check() -> Bool { + let outcome = publication_guard_local_check( + roster: granted_roster(), + added_paths: [synthetic_added_path], + head: synthetic_head, + ) + match admit_public_write( + session: synthetic_ordinary_session, + head: synthetic_head, + pushed_refs: [synthetic_push()], + outcome: outcome, + ) { + PublicWriteAdmitted { authority: _ } => false + PublicWriteRefused { session: session, cause: cause } => + session == synthetic_ordinary_session + && match cause { + NotTheSolePublisher { session: refused, sole_publisher: sole } => + refused == synthetic_ordinary_session && sole == sole_modeled_publisher_session + ProjectionNotAdmitted { session: _, detail: _ } => false + UnstampedPublicContent { session: _, path: _ } => false + UnauthorizedPublicPush { session: _, push: _, detail: _ } => false + } + } +} + +test fn sole_publisher_reds_on_ungranted_projection() -> Bool { + let outcome = publication_guard_local_check( + roster: ungranted_roster(), + added_paths: [synthetic_added_path], + head: synthetic_head, + ) + match outcome { + PublishGuardLocalAccepted { receipt: _ } => false + PublishGuardLocalRefused { receipt: _, violations: violations } => + violations.count() == 1 + && match violations.first() { + AddedPathPublishGrantAbsent { path: path, root: root, audience: _ } => + path == synthetic_added_path && root == "." + _ => false + } + } + && match admit_public_write( + session: sole_modeled_publisher_session, + head: synthetic_head, + pushed_refs: [synthetic_push()], + outcome: outcome, + ) { + PublicWriteAdmitted { authority: _ } => false + PublicWriteRefused { session: session, cause: cause } => + session == sole_modeled_publisher_session + && match cause { + ProjectionNotAdmitted { session: refused, detail: detail } => + refused == sole_modeled_publisher_session + && string_contains(s: detail, pattern: "placement gate refused") + NotTheSolePublisher { session: _, sole_publisher: _ } => false + UnstampedPublicContent { session: _, path: _ } => false + UnauthorizedPublicPush { session: _, push: _, detail: _ } => false + } + } +} + +test fn sole_publisher_greens_on_admitted_projection() -> Bool { + let outcome = publication_guard_local_check( + roster: granted_roster(), + added_paths: [synthetic_added_path], + head: synthetic_head, + ) + match outcome { + PublishGuardLocalAccepted { receipt: receipt } => + match admit_public_write( + session: sole_modeled_publisher_session, + head: synthetic_head, + pushed_refs: [synthetic_push()], + outcome: outcome, + ) { + PublicWriteAdmitted { authority: authority } => + authority.publisher_session == sole_modeled_publisher_session + && authority.projection.receipt.root == "." + && authority.projection.receipt.added_paths == [synthetic_added_path] + && authority.projection.admission.source.head == synthetic_head + PublicWriteRefused { session: _, cause: _ } => false + } + PublishGuardLocalRefused { receipt: _, violations: _ } => false + } +} + +test fn shadow_observer_refuses_ordinary_session_push() -> Bool { + let entries = shadow_replay_public_pushes( + roster: granted_roster(), + events: [ObservedPublicPush { + session: synthetic_ordinary_session, + head: synthetic_head, + push: synthetic_push(), + added_paths: [synthetic_added_path], + }], + ) + entries.count() == 1 + && shadow_refusal_count(entries: entries) == 1 + && shadow_would_refuse_ordinary_session( + roster: granted_roster(), + observed: ObservedPublicPush { + session: synthetic_ordinary_session, + head: synthetic_head, + push: synthetic_push(), + added_paths: [synthetic_added_path], + }, + ) + && match entries.first() { + Present { value: entry } => + match entry { + ShadowPushWouldRefuse { session: session, head: head, push: push, admission: admission } => + session == synthetic_ordinary_session + && head == synthetic_head + && push.local_sha == synthetic_head + ShadowPushWouldAdmit { session: _, head: _, push: _ } => false + } + Absent => false + } +} + +test fn shadow_observer_admits_sole_publisher_with_grant() -> Bool { + let entries = shadow_replay_public_pushes( + roster: granted_roster(), + events: [ObservedPublicPush { + session: sole_modeled_publisher_session, + head: synthetic_head, + push: synthetic_push(), + added_paths: [synthetic_added_path], + }], + ) + entries.count() == 1 + && shadow_refusal_count(entries: entries) == 0 + && match entries.first() { + Present { value: entry } => + match entry { + ShadowPushWouldAdmit { session: session, head: head, push: _ } => + session == sole_modeled_publisher_session && head == synthetic_head + ShadowPushWouldRefuse { session: _, head: _, push: _, admission: _ } => false + } + Absent => false + } +} diff --git a/dag/test/claim/visibility_grant_witness_test.dag b/dag/test/claim/visibility_grant_witness_test.dag deleted file mode 100644 index 8d793ab7ca1..00000000000 --- a/dag/test/claim/visibility_grant_witness_test.dag +++ /dev/null @@ -1,169 +0,0 @@ -module test.claim.visibility_grant_witness_test - -import std.types { Bool, String, List } -import std.effect_grant { - Verb, Reference, Publish, - CodeNameTree, NamespacePosition, - RealTransport, LifecycleByConstruction, - Grant, -} -import std.visibility_grant { - LegacyOpen, Declared, NodeVisibility, - ReferenceAdmission, ReferenceAdmitted, NameNotVisible, visible_from, - PublishAdmission, PublishAdmitted, PublishAudienceNotGranted, publish_to, - PublishAncestorCompatible, PublishAncestorNarrower, publish_ancestor_rule, - PublishClosureClosed, PublishClosureGap, publish_closure_rule, - world_audience, network_audience, org_audience, repo_audience, -} -import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } - -data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly - -fn code_position(path: List) -> NamespacePosition { - NamespacePosition { tree: CodeNameTree, path: path } -} - -fn grant(verb: Verb, root: NamespacePosition) -> Grant { - Grant { - verb: verb, - root: root, - binding: RealTransport, - lifecycle: LifecycleByConstruction, - } -} - -fn declared(node: NamespacePosition, grants: List) -> NodeVisibility { - NodeVisibility { node: node, status: Declared { grants: grants } } -} - -data helper_node: NamespacePosition = code_position(path: ["app", "internal", "helper"]) -data internal_referrer: NamespacePosition = code_position(path: ["app", "internal", "caller"]) -data sibling_referrer: NamespacePosition = code_position(path: ["app", "other", "caller"]) -data friend_root: NamespacePosition = code_position(path: ["app", "friend"]) -data friend_referrer: NamespacePosition = code_position(path: ["app", "friend", "caller"]) - -fn reference_admitted(a: ReferenceAdmission) -> Bool { - match a { - ReferenceAdmitted => true - NameNotVisible { node: node, referrer: referrer } => false - } -} - -test fn reference_visibility_green_and_red_controls() -> Bool { - let private_helper = declared(node: helper_node, grants: []) - let friend_helper = declared(node: helper_node, grants: [grant(verb: Reference, root: friend_root)]) - reference_admitted(a: visible_from(visibility: private_helper, referrer: internal_referrer)) - && !reference_admitted(a: visible_from(visibility: private_helper, referrer: sibling_referrer)) - && reference_admitted(a: visible_from(visibility: friend_helper, referrer: friend_referrer)) - && !reference_admitted(a: visible_from(visibility: friend_helper, referrer: sibling_referrer)) -} - -test fn reference_refusal_is_located() -> Bool { - match visible_from( - visibility: declared(node: helper_node, grants: []), - referrer: sibling_referrer, - ) { - NameNotVisible { node: node, referrer: referrer } => - node == helper_node && referrer == sibling_referrer - ReferenceAdmitted => false - } -} - -fn publish_admitted_witness(a: PublishAdmission) -> Bool { - match a { - PublishAdmitted { node: node, audience: audience } => true - PublishAudienceNotGranted { node: node, audience: audience } => false - } -} - -data public_node_position: NamespacePosition = code_position(path: ["app", "public_api"]) -data private_node_position: NamespacePosition = code_position(path: ["app", "private_impl"]) - -fn public_node() -> NodeVisibility { - declared( - node: public_node_position, - grants: [grant(verb: Publish, root: world_audience())], - ) -} - -fn private_node() -> NodeVisibility { - declared( - node: private_node_position, - grants: [grant(verb: Publish, root: repo_audience())], - ) -} - -test fn audience_tree_and_publish_grant_red_controls() -> Bool { - publish_admitted_witness(a: publish_to(visibility: public_node(), audience: world_audience())) - && publish_admitted_witness(a: publish_to(visibility: public_node(), audience: network_audience())) - && publish_admitted_witness(a: publish_to(visibility: public_node(), audience: org_audience())) - && publish_admitted_witness(a: publish_to(visibility: public_node(), audience: repo_audience())) - && publish_admitted_witness(a: publish_to(visibility: private_node(), audience: repo_audience())) - && !publish_admitted_witness(a: publish_to(visibility: private_node(), audience: org_audience())) - && !publish_admitted_witness(a: publish_to(visibility: private_node(), audience: world_audience())) -} - -test fn publish_audience_refusal_is_located() -> Bool { - match publish_to(visibility: private_node(), audience: world_audience()) { - PublishAudienceNotGranted { node: node, audience: audience } => - node == private_node_position && audience == world_audience() - PublishAdmitted { node: node, audience: audience } => false - } -} - -test fn undeclared_publish_defaults_to_narrowest_audience_only() -> Bool { - let legacy = NodeVisibility { node: private_node_position, status: LegacyOpen } - publish_admitted_witness(a: publish_to(visibility: legacy, audience: repo_audience())) - && !publish_admitted_witness(a: publish_to(visibility: legacy, audience: org_audience())) - && !publish_admitted_witness(a: publish_to(visibility: legacy, audience: world_audience())) -} - -test fn publish_ancestor_narrower_red_control_is_located() -> Bool { - match publish_ancestor_rule( - ancestor: private_node(), - child: public_node(), - audience: world_audience(), - ) { - PublishAncestorNarrower { ancestor: ancestor, child: child, audience: audience } => - ancestor == private_node_position - && child == public_node_position - && audience == world_audience() - PublishAncestorCompatible => false - } -} - -test fn publish_ancestor_compatible_green_control() -> Bool { - match publish_ancestor_rule( - ancestor: public_node(), - child: private_node(), - audience: world_audience(), - ) { - PublishAncestorCompatible => true - PublishAncestorNarrower { ancestor: ancestor, child: child, audience: audience } => false - } -} - -test fn publish_closure_gap_red_control_is_located() -> Bool { - match publish_closure_rule( - referrer: public_node(), - referenced: private_node(), - audience: world_audience(), - ) { - PublishClosureGap { referrer: referrer, referenced: referenced, audience: audience } => - referrer == public_node_position - && referenced == private_node_position - && audience == world_audience() - PublishClosureClosed => false - } -} - -test fn publish_closure_closed_green_control() -> Bool { - match publish_closure_rule( - referrer: private_node(), - referenced: public_node(), - audience: repo_audience(), - ) { - PublishClosureClosed => true - PublishClosureGap { referrer: referrer, referenced: referenced, audience: audience } => false - } -} diff --git a/dag/tools/publication_placement_gate.dag b/dag/tools/publication_placement_gate.dag index 6e88c5ed6e0..da5b8c31203 100644 --- a/dag/tools/publication_placement_gate.dag +++ b/dag/tools/publication_placement_gate.dag @@ -2,12 +2,9 @@ module tools.publication_placement_gate import std.types { CommitSha, FilePath, GitRef, Int, List, String } import std.process { ProcessExit, ExitSuccess, exit_failure } -import std.effect_grant { - Publish, NamespacePosition, EffectAdmission, Admitted, EffectOutsideGrant, - admit_effect, -} -import std.materialization_ladder { Frame } -import std.visibility_grant { world_audience } +import std.access { AccessDecision, AccessRefusal, Permit, Deny, access_refusal_reason } +import std.authorization_profile { AudienceSet } +import gunbc.principal_projection { GunbcPrincipal } import extdeps.git { GitDiffStatusEntry, DiffAddedStatus, DiffCopiedStatus, DiffRenamedStatus, DiffUnknownGitStatus, @@ -19,13 +16,14 @@ import extdeps.git { import extdeps.git import gunbc.publication_grant { PublicRootPublishBaseline, PublicFilePublishGrant, PublicPublicationGrantRoster, - public_publication_grant_roster, + public_publication_grant_roster, public_audience, + public_publication_request, decide_publication_request, } import gunbc.publication_cutover { public_publication_root, public_publication_cutover_sha, } -data publication_placement_gate_note: String = "Server-side Stage-0 publication wall. Before observing a diff it validates the roster baseline against the fixed gunbc.publication_cutover authority, so a roster edit cannot advance the grandfathered corpus. It observes ONLY the mounted public checkout, computes new public paths whose cutover-to-HEAD git status is Added, Copied, or Renamed, locates each path's roster envelope, and delegates the Publish World decision to std.effect_grant.admit_effect. A changed baseline, missing/ambiguous markers, refused grants, git failures, truncated parses, and unknown statuses are distinct typed PublishGuardLocalViolation causes; none widens to a scan or proceeds. The coverage receipt names exactly the public mount, the roster markers, and new paths checked. It makes no PublishClosureGap/PublishAncestorNarrower or private-root claim. This becomes the enforcement wall when the operator makes the CI check required in branch protection; no client hook is an authority." +data publication_placement_gate_note: String = "Server-side Stage-0 publication wall. Before observing a diff it validates the roster baseline against the fixed gunbc.publication_cutover authority, so a roster edit cannot advance the grandfathered corpus. It observes ONLY the mounted public checkout, computes new public paths whose cutover-to-HEAD git status is Added, Copied, or Renamed, locates each path's roster evidence, constructs a PublicationAdmissionRequest for the universal AudienceSet, and delegates the sole allow/deny decision to std.access.AccessPolicy. A changed baseline, missing/ambiguous markers, refused evidence, git failures, truncated parses, and unknown statuses remain distinct typed PublishGuardLocalViolation causes; none widens to a scan or proceeds. The coverage receipt names exactly the public mount, the roster markers, and new paths checked. tools.publication_publisher is the complementary WHO layer: only its sole modeled publisher may exercise public-write authority, and only against a PublishGuardLocalAccepted admitted projection — hook/check-only client enforcement is not an authority for either layer." type PublishGuardPublicRootOnly {} @@ -54,14 +52,12 @@ type PublishGuardLocalViolation | BaselinePublishGrantRefused { root: FilePath, cutover: String, - verb: std.effect_grant.Verb, - target: NamespacePosition, - frame: Frame, + refusal: AccessRefusal, } | AddedPathPublishGrantAbsent { path: FilePath, root: FilePath, - audience: NamespacePosition, + audience: AudienceSet, } | FilePublishGrantMarkerRepeated { path: FilePath, @@ -70,22 +66,18 @@ type PublishGuardLocalViolation | FilePublishGrantMarkerRefused { path: FilePath, root: FilePath, - verb: std.effect_grant.Verb, - target: NamespacePosition, - frame: Frame, + refusal: AccessRefusal, } | AddedPathPublishGrantAmbiguous { path: FilePath, root: FilePath, - audience: NamespacePosition, + audience: AudienceSet, marker_count: Int, } | AddedPathPublishGrantRefused { path: FilePath, root: FilePath, - verb: std.effect_grant.Verb, - target: NamespacePosition, - frame: Frame, + refusal: AccessRefusal, } | PublicRootDiffReadFailure { root: FilePath, @@ -142,17 +134,15 @@ fn public_file_grant_lookup( fn admission_refusal_for_baseline( baseline: PublicRootPublishBaseline, - admission: EffectAdmission, + admission: AccessDecision, ) -> List { match admission { - Admitted { via_root: via_root } => [] - EffectOutsideGrant { verb: verb, target: target, frame: frame } => [ + Permit => [] + Deny { refusal: refusal } => [ BaselinePublishGrantRefused { root: baseline.root, cutover: baseline.cutover, - verb: verb, - target: target, - frame: frame, + refusal: refusal, } ] } @@ -177,17 +167,15 @@ fn baseline_policy_violations( fn admission_refusal_for_path( path: FilePath, root: FilePath, - admission: EffectAdmission, + admission: AccessDecision, ) -> List { match admission { - Admitted { via_root: via_root } => [] - EffectOutsideGrant { verb: verb, target: target, frame: frame } => [ + Permit => [] + Deny { refusal: refusal } => [ AddedPathPublishGrantRefused { path: path, root: root, - verb: verb, - target: target, - frame: frame, + refusal: refusal, } ] } @@ -197,15 +185,17 @@ fn admission_refusal_for_marker( grant: PublicFilePublishGrant, root: FilePath, ) -> List { - match admit_effect(env: grant.envelope, verb: Publish, target: world_audience()) { - Admitted { via_root: via_root } => [] - EffectOutsideGrant { verb: verb, target: target, frame: frame } => [ + match decide_publication_request(request: public_publication_request( + resource: grant.path, + root: root, + grant: grant.grant, + )) { + Permit => [] + Deny { refusal: refusal } => [ FilePublishGrantMarkerRefused { path: grant.path, root: root, - verb: verb, - target: target, - frame: frame, + refusal: refusal, } ] } @@ -236,7 +226,7 @@ fn added_path_violations( path: FilePath, roster: PublicPublicationGrantRoster, ) -> List { - let audience = world_audience() + let audience = public_audience() match public_file_grant_lookup(path: path, grants: roster.file_grants) { PublicFileGrantAbsent => [AddedPathPublishGrantAbsent { path: path, @@ -252,7 +242,11 @@ fn added_path_violations( PublicFileGrantFound { grant: grant } => admission_refusal_for_path( path: path, root: roster.baseline.root, - admission: admit_effect(env: grant.envelope, verb: Publish, target: audience), + admission: decide_publication_request(request: public_publication_request( + resource: path, + root: roster.baseline.root, + grant: grant.grant, + )), ) } } @@ -266,12 +260,16 @@ fn publication_guard_local_check( added_paths: List, head: String, ) -> PublishGuardLocalOutcome { - let audience = world_audience() + let audience = public_audience() let baseline_violations = append( baseline_policy_violations(baseline: roster.baseline), items: admission_refusal_for_baseline( baseline: roster.baseline, - admission: admit_effect(env: roster.baseline.envelope, verb: Publish, target: audience), + admission: decide_publication_request(request: public_publication_request( + resource: roster.baseline.root, + root: roster.baseline.root, + grant: roster.baseline.grant, + )), ), ) let marker_audit = file_marker_audit( @@ -396,18 +394,18 @@ fn render_publish_guard_local_violation(violation: PublishGuardLocalViolation) - match violation { BaselinePlacementPolicyNotPinned { declared_root, pinned_root, declared_cutover, pinned_cutover } => join(["publication baseline is not the pinned policy: declared_root=", declared_root, " pinned_root=", pinned_root, " declared_cutover=", declared_cutover, " pinned_cutover=", pinned_cutover], "") - BaselinePublishGrantRefused { root, cutover, verb, target, frame } => - join(["baseline Publish World grant refused: root=", root, " cutover=", cutover, " frame=", frame.name], "") + BaselinePublishGrantRefused { root, cutover, refusal } => + join(["baseline universal-audience publication refused: root=", root, " cutover=", cutover, " reason=", access_refusal_reason(refusal: refusal)], "") AddedPathPublishGrantAbsent { path, root, audience } => join(["added public path has no publication grant: path=", path, " root=", root], "") AddedPathPublishGrantAmbiguous { path, root, audience, marker_count } => join(["added public path has ambiguous publication grants: path=", path, " root=", root, " markers=", to_string(marker_count)], "") FilePublishGrantMarkerRepeated { path, root } => join(["publication grant marker is repeated: path=", path, " root=", root], "") - FilePublishGrantMarkerRefused { path, root, verb, target, frame } => - join(["publication grant marker is not granted Publish World: path=", path, " root=", root, " frame=", frame.name], "") - AddedPathPublishGrantRefused { path, root, verb, target, frame } => - join(["added public path is not granted Publish World: path=", path, " root=", root, " frame=", frame.name], "") + FilePublishGrantMarkerRefused { path, root, refusal } => + join(["publication marker does not admit the universal audience: path=", path, " root=", root, " reason=", access_refusal_reason(refusal: refusal)], "") + AddedPathPublishGrantRefused { path, root, refusal } => + join(["added public path does not admit the universal audience: path=", path, " root=", root, " reason=", access_refusal_reason(refusal: refusal)], "") PublicRootDiffReadFailure { root, cutover, head } => join(["public-root git.Core.DiffNameStatus failed: root=", root, " cutover=", cutover, " head=", head], "") PublicRootDiffParseTruncated { root, cutover, head, pending } => diff --git a/dag/tools/publication_publisher.dag b/dag/tools/publication_publisher.dag new file mode 100644 index 00000000000..839f1f8666f --- /dev/null +++ b/dag/tools/publication_publisher.dag @@ -0,0 +1,241 @@ +module tools.publication_publisher + +import std.types { Bool, FilePath, List, NonEmptyStr, String } +import std.authorization_profile { + AudienceSet, UniversalAudience, + PublicationAdmissionRequest, +} +import gunbc.principal_projection { GunbcPrincipal } +import extdeps.git.hooks { PrePushStdinRow } +import tools.publication_placement_gate { + PublishGuardLocalOutcome, + PublishGuardLocalAccepted, + PublishGuardLocalRefused, + PublishGuardLocalCoverageReceipt, +} + +data publication_publisher_note: String = "Stage-0 sole modeled publisher over admitted public projections (priority-assessment directive section 8; supersedes hook/check-only enforcement as the WHO layer atop P-B's WHAT layer). Ordinary ModeledSessionId values are OrdinarySession and carry no PublicationAuthority. Exactly one sole_modeled_publisher_session holds public-write capability, and only when it consumes a std.authorization_profile.PublicationAdmissionRequest built from a PublishGuardLocalAccepted receipt. Hooks and required CI checks remain fast diagnostics; this module constrains who may bind extdeps.git.publication_transport write operations at all. Slice 1 is model + shadow only — no live push path, credential, or remote mutation." + +type ModeledSessionId = NonEmptyStr where brand("ModeledSessionId") + +type PublisherPlacementSource { + head: String + pushed_refs: List +} + +type PublisherPlacementResource { + public_root: FilePath + cutover: String +} + +type PublisherWriteContext { + stage: String +} + +type PublisherWriteEvidence { + baseline_marker_checked: Bool + file_marker_paths: List + added_paths: List +} + +type PublisherPublicationAdmissionRequest = PublicationAdmissionRequest + +type SessionPublicationPosture + = OrdinarySession { session: ModeledSessionId } + | SoleModeledPublisher { session: ModeledSessionId } + +type AdmittedPublicProjection { + receipt: PublishGuardLocalCoverageReceipt + admission: PublisherPublicationAdmissionRequest +} + +type PublicationAuthority { + publisher_session: ModeledSessionId + projection: AdmittedPublicProjection +} + +type PublicWriteAdmission + = PublicWriteAdmitted { authority: PublicationAuthority } + | PublicWriteRefused { + session: ModeledSessionId, + cause: PublicWriteRefusal, + } + +type PublicWriteRefusal + = NotTheSolePublisher { + session: ModeledSessionId, + sole_publisher: ModeledSessionId, + } + | ProjectionNotAdmitted { + session: ModeledSessionId, + detail: String, + } + | UnstampedPublicContent { + session: ModeledSessionId, + path: String, + } + | UnauthorizedPublicPush { + session: ModeledSessionId, + push: PrePushStdinRow, + detail: String, + } + +data sole_modeled_publisher_session: ModeledSessionId = "gunbc-publication-publisher" as ModeledSessionId + +fn publisher_public_audience() -> AudienceSet { + UniversalAudience +} + +fn publication_admission_request_from_receipt( + subject: ModeledSessionId, + head: String, + pushed_refs: List, + receipt: PublishGuardLocalCoverageReceipt, +) -> PublisherPublicationAdmissionRequest { + PublicationAdmissionRequest { + subject: subject, + source: PublisherPlacementSource { + head: head, + pushed_refs: pushed_refs, + }, + resource: PublisherPlacementResource { + public_root: receipt.root, + cutover: receipt.cutover, + }, + audience: publisher_public_audience(), + context: PublisherWriteContext { stage: "slice-1-shadow" }, + evidence: PublisherWriteEvidence { + baseline_marker_checked: true, + file_marker_paths: receipt.file_marker_paths, + added_paths: receipt.added_paths, + }, + } +} + +fn session_publication_posture(session: ModeledSessionId) -> SessionPublicationPosture { + if session == sole_modeled_publisher_session { + SoleModeledPublisher { session: session } + } else { + OrdinarySession { session: session } + } +} + +fn posture_is_sole_publisher(posture: SessionPublicationPosture) -> Bool { + match posture { + SoleModeledPublisher { session: _ } => true + OrdinarySession { session: _ } => false + } +} + +fn admitted_public_projection_from_outcome( + subject: ModeledSessionId, + head: String, + pushed_refs: List, + outcome: PublishGuardLocalOutcome, +) -> AdmittedPublicProjection? { + match outcome { + PublishGuardLocalAccepted { receipt: receipt } => + Present { + value: AdmittedPublicProjection { + receipt: receipt, + admission: publication_admission_request_from_receipt( + subject: subject, + head: head, + pushed_refs: pushed_refs, + receipt: receipt, + ), + }, + } + PublishGuardLocalRefused { receipt: _, violations: _ } => Absent + } +} + +fn public_write_refusal_detail(outcome: PublishGuardLocalOutcome) -> String { + match outcome { + PublishGuardLocalAccepted { receipt: _ } => "placement gate accepted the public projection" + PublishGuardLocalRefused { receipt: receipt, violations: violations } => + join([ + "placement gate refused the public projection: violation_count=", + to_string(violations.count()), + ], "") + } +} + +fn admit_public_write( + session: ModeledSessionId, + head: String, + pushed_refs: List, + outcome: PublishGuardLocalOutcome, +) -> PublicWriteAdmission { + match session_publication_posture(session: session) { + OrdinarySession { session: ordinary } => + PublicWriteRefused { + session: ordinary, + cause: NotTheSolePublisher { + session: ordinary, + sole_publisher: sole_modeled_publisher_session, + }, + } + SoleModeledPublisher { session: publisher } => + match admitted_public_projection_from_outcome( + subject: publisher, + head: head, + pushed_refs: pushed_refs, + outcome: outcome, + ) { + Present { value: projection } => + PublicWriteAdmitted { + authority: PublicationAuthority { + publisher_session: publisher, + projection: projection, + }, + } + Absent => + PublicWriteRefused { + session: publisher, + cause: ProjectionNotAdmitted { + session: publisher, + detail: public_write_refusal_detail(outcome: outcome), + }, + } + } + } +} + +fn public_write_admission_fold( + admission: PublicWriteAdmission, + on_admitted: fn(PublicationAuthority) -> T, + on_refused: fn(ModeledSessionId, PublicWriteRefusal) -> T, +) -> T { + match admission { + PublicWriteAdmitted { authority: authority } => on_admitted(authority) + PublicWriteRefused { session: session, cause: cause } => on_refused(session, cause) + } +} + +fn public_write_admitted(admission: PublicWriteAdmission) -> Bool { + public_write_admission_fold( + admission: admission, + on_admitted: fn(authority) { true }, + on_refused: fn(session, cause) { false }, + ) +} + +fn ordinary_session_lacks_public_write_authority(session: ModeledSessionId) -> Bool { + !posture_is_sole_publisher(posture: session_publication_posture(session: session)) +} + +fn refuse_unstamped_public_content( + session: ModeledSessionId, + path: String, +) -> PublicWriteRefusal { + UnstampedPublicContent { session: session, path: path } +} + +fn refuse_unauthorized_public_push( + session: ModeledSessionId, + push: PrePushStdinRow, + detail: String, +) -> PublicWriteRefusal { + UnauthorizedPublicPush { session: session, push: push, detail: detail } +} diff --git a/dag/tools/publication_push_shadow.dag b/dag/tools/publication_push_shadow.dag new file mode 100644 index 00000000000..32ec13fcc8a --- /dev/null +++ b/dag/tools/publication_push_shadow.dag @@ -0,0 +1,116 @@ +module tools.publication_push_shadow + +import std.types { Bool, Int, List, String } +import extdeps.git.hooks { PrePushStdinRow } +import tools.publication_publisher { + ModeledSessionId, + PublicWriteAdmission, + PublicWriteRefused, + PublicWriteAdmitted, + NotTheSolePublisher, + ProjectionNotAdmitted, + UnstampedPublicContent, + UnauthorizedPublicPush, + admit_public_write, +} +import gunbc.publication_grant { PublicPublicationGrantRoster } +import tools.publication_placement_gate { + publication_guard_local_check, + PublishGuardLocalOutcome, +} + +data publication_push_shadow_note: String = "Slice-1 shadow witness: a read-only stopped-line-audit observer that replays observed public pushes through the modeled admission stack and ledgers which would have been refused. It reports typed, located, counted refusals; it does not green, gate, or mutate live push paths. The receipt sizes the enforcement stage by naming ordinary-session pushes that today reach the public remote unmodeled." + +type ObservedPublicPush { + session: ModeledSessionId + head: String + push: PrePushStdinRow + added_paths: List +} + +type ShadowPushLedgerEntry + = ShadowPushWouldAdmit { + session: ModeledSessionId + head: String + push: PrePushStdinRow + } + | ShadowPushWouldRefuse { + session: ModeledSessionId + head: String + push: PrePushStdinRow + admission: PublicWriteAdmission + } + +fn shadow_push_admission( + roster: PublicPublicationGrantRoster, + observed: ObservedPublicPush, +) -> PublicWriteAdmission { + admit_public_write( + session: observed.session, + head: observed.head, + pushed_refs: [observed.push], + outcome: publication_guard_local_check( + roster: roster, + added_paths: observed.added_paths, + head: observed.head, + ), + ) +} + +fn shadow_push_ledger_entry( + roster: PublicPublicationGrantRoster, + observed: ObservedPublicPush, +) -> ShadowPushLedgerEntry { + let admission = shadow_push_admission(roster: roster, observed: observed) + match admission { + PublicWriteAdmitted { authority: _ } => + ShadowPushWouldAdmit { + session: observed.session, + head: observed.head, + push: observed.push, + } + PublicWriteRefused { session: session, cause: cause } => + ShadowPushWouldRefuse { + session: session, + head: observed.head, + push: observed.push, + admission: admission, + } + } +} + +fn shadow_replay_public_pushes( + roster: PublicPublicationGrantRoster, + events: List, +) -> List { + map(events, event => shadow_push_ledger_entry(roster: roster, observed: event)) +} + +fn shadow_refusal_count(entries: List) -> Int { + fold(entries, init: 0, f: fn(count, entry) { + match entry { + ShadowPushWouldAdmit { session: _, head: _, push: _ } => count + ShadowPushWouldRefuse { session: _, head: _, push: _, admission: _ } => count + 1 + } + }) +} + +fn shadow_would_refuse_ordinary_session( + roster: PublicPublicationGrantRoster, + observed: ObservedPublicPush, +) -> Bool { + match shadow_push_ledger_entry(roster: roster, observed: observed) { + ShadowPushWouldAdmit { session: _, head: _, push: _ } => false + ShadowPushWouldRefuse { session: _, head: _, push: _, admission: admission } => + match admission { + PublicWriteRefused { session: _, cause: cause } => + match cause { + NotTheSolePublisher { session: _, sole_publisher: _ } => true + ProjectionNotAdmitted { session: _, detail: _ } => false + UnstampedPublicContent { session: _, path: _ } => false + UnauthorizedPublicPush { session: _, push: _, detail: _ } => false + } + PublicWriteAdmitted { authority: _ } => false + } + } +} diff --git a/docs/plans/sole-modeled-publisher-design.md b/docs/plans/sole-modeled-publisher-design.md new file mode 100644 index 00000000000..7afa254c6aa --- /dev/null +++ b/docs/plans/sole-modeled-publisher-design.md @@ -0,0 +1,101 @@ +# Sole modeled publisher — staged migration design + +Status: DRAFT for operator review (slice 1 landed as model + shadow only, 2026-08-01). + +Origin: priority-assessment directive section 8 — publication safety outranks reveal UX. Repeated unmodeled auto-push incidents (three specimens on 2026-08-01) pushed unverified session work to the public remote because `git.Core` had no modeled `Push`/`Commit` write surface and publication was raw argv nobody's model saw. + +## Evidence specimens (2026-08-01) + +Recorded motivation receipts for the sole-publisher lane; no action items beyond citation. + +### Unmodeled session pushes (opening specimens) + +Three dashboard worker auto-push incidents the same day: session branches reached the public GitHub remote with no modeled `Push`/`Commit` in `extdeps.git` and no `PublicationAuthority` gate on **who** may write. + +### Publication roster races (placement-gate landing, #7560 / repair #7580) + +While the placement gate was rolling out, merge refs raced the roster: + +1. **Missing rows** — branches cut before the gate landed merged to `main` carrying post-cutover `Added` paths with no `PublicFilePublishGrant` row; every subsequent PR merge ref refused `publication_placement_gate_passes` until repair #7580 stamped eleven paths. +2. **Duplicated rows** — parallel lane fixes merged without textual conflict markers but duplicated grant rows in `gunbc.publication_grant` (same path twice, valid syntax, invalid policy). +3. **Evaluate-then-merge TOCTOU** — the gate observes cutover→HEAD at merge-ref evaluation time; concurrent merges can change which paths are `Added` between evaluation and landing. An **admitted projection** model closes this by binding publication to a receipt over the exact source head and path set being published, not a one-shot gate run that later merges assume still hold. + +### CI auto-heal unmodeled push (purest specimen yet) + +During the roster races, CI auto-heal machinery **committed and pushed an unresolved merge** to worker branch `eager-seal-327` (commit `218dea6`): literal `<<<<<<<` / `=======` / `>>>>>>>` conflict markers remained in `dag/gunbc/publication_grant.dag`, producing parse errors downstream. An automated actor with push capability published syntactically invalid content nobody reviewed. + +Under the sole-publisher architecture this incident class is **unwritable twice over**: the heal bot would hold no `PublicationAuthority` (refused `NotTheSolePublisher` before any placement check), and an admitted `PublishGuardLocalAccepted` projection could never contain conflict markers — invalid syntax is not an admissible public write. + +## Problem statement + +Today every dashboard worker and the autocommit machinery push session branches directly to the public GitHub remote. Hooks and CI placement gates are fast diagnostics and merge protection, but they do not constrain **who** may write to public storage. Confidentiality requires that ordinary sessions cannot hold public-write capability at all. + +Target architecture (operator sketch): a separate bucket/server is source of truth; the public GitHub repo is a **downstream mirror**. Under that shape, bypassing the wall stops being possible because sessions never touch the public remote. + +## Slice 1 boundary (this PR) + +**Hard rule:** no live push path, credential custody, or remote mutation. The fleet continues pushing session branches to the public repo. + +Delivered: + +| artifact | role | +|---|---| +| `extdeps.git.publication_transport` | Modeled `PushRefUpdates` and `RecordCommit` interface shapes; argv is one transport handler | +| `tools.publication_publisher` | `PublicationAuthority` — sole publisher + admitted projection via `std.authorization_profile.PublicationAdmissionRequest`; typed refusals | +| `tools.publication_push_shadow` | Read-only stopped-line-audit replay; reports, does not green or gate | +| witness tests | Green-by-execution with discriminating REDs | + +## WHO × WHAT composition + +- **WHAT (landed P-B):** `tools.publication_placement_gate` decides which paths may inhabit the universal audience projection. +- **WHO (this slice):** `tools.publication_publisher` decides which session may bind `git.PublicationTransport` write operations against an admitted projection. + +Ordinary sessions are refused `NotTheSolePublisher` before any placement outcome is consulted. The sole publisher without `PublishGuardLocalAccepted` is refused `ProjectionNotAdmitted`. + +## PublicationAdmissionRequest consumer contract + +Fields the sole-publisher consumer needs from proud-wolf-486's carrier (coordinate, do not fork): + +- `subject` — `ModeledSessionId` / principal attempting the write +- `source.head` — exact commit sha under judgment +- `source.pushed_refs` — `List` from `extdeps.git.hooks` +- `resource.public_root` + `resource.cutover` — pinned public mount policy +- `audience` — `UniversalAudience` (`AudienceSet`) +- `evidence` — baseline marker + file markers + added paths from the placement receipt +- `context` — publisher write stage marker (`PublisherWriteContext`) + +## Staged migration (fleet stays working at every step) + +### Stage 0 — model + shadow (this PR) + +- Model write operations and publisher authority. +- Shadow observer replays observed pushes; ledger counts ordinary-session refusals. +- **Trigger to Stage 1:** shadow receipt shows stable refusal taxonomy on live specimens; proud-wolf admission profile landed. + +### Stage 1 — staging remote / bucket writes + +- Sessions push to a **private staging remote** (or bucket) only; public remote credentials removed from worker reach. +- Autocommit seam retargeted to staging; fleet keeps working on session branches. +- Publisher process (separate session/host) mirrors admitted refs to public. +- **Rollback:** restore worker push credentials to public remote; disable publisher mirror. + +### Stage 2 — enforcement gate + +- Bind `git.PublicationTransport` dispatch: refuse unless `PublicationAuthority` is present. +- Shadow witness becomes regression control; live gate refuses instead of only reporting. +- **Rollback:** feature flag returns to shadow-only; staging remote remains. + +### Stage 3 — downstream mirror architecture + +- Public GitHub repo is generated projection only; sessions never receive public-write credentials. +- Autocommit machinery dissolved into staging + publisher pipeline. +- **Rollback:** not instant — requires credential reprovision and mirror pause (operator migration). + +## Autocommit seam fate + +The dashboard auto-committer currently snapshots multi-file edits mid-task and pushes session branches to the public remote. Stage 1 retargets it to staging only. Stage 3 replaces "push" with "submit to staging + request mirror" where the sole publisher is the only actor that can advance content to the public remote. + +## Open coordination + +- **proud-wolf-486 (#7578):** `std.authorization_profile.PublicationAdmissionRequest` landed; provisional `tools.publication_admission` bridge dissolved. +- **P-C composed-graph wall:** independent; does not block Stage 1 staging remote work.