From ea8eb0df070271630934768bc922f00edc841a0c Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 19:32:28 +0000 Subject: [PATCH 01/24] Model reported printer setup and prepare Altra fit gauges --- dag/gunbc/fleet/printer_setup_reports.dag | 97 +++++++ .../product/printed_chassis/fit_gauges.dag | 99 ++++++++ dag/test/claim/fit_gauges_witness_test.dag | 35 +++ .../asrock_rack/ALTRAD8UD_standoff_grid.png | Bin 0 -> 156177 bytes docs/plans/printed-chassis-program.md | 238 +++++++++++++++--- 5 files changed, 438 insertions(+), 31 deletions(-) create mode 100644 dag/gunbc/fleet/printer_setup_reports.dag create mode 100644 dag/gunbc/product/printed_chassis/fit_gauges.dag create mode 100644 dag/test/claim/fit_gauges_witness_test.dag create mode 100644 docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png diff --git a/dag/gunbc/fleet/printer_setup_reports.dag b/dag/gunbc/fleet/printer_setup_reports.dag new file mode 100644 index 00000000000..bfcec9230f9 --- /dev/null +++ b/dag/gunbc/fleet/printer_setup_reports.dag @@ -0,0 +1,97 @@ +module gunbc.fleet.printer_setup_reports + +import std.types { String, NonEmptyStr, List } +import extdeps.printing.fdm { FilamentMaterial, Pla } +import gunbc.fleet_asset_identity { PhysicalAssetIdentity, printer_01, printer_02 } +import gunbc.cli_wire { CliWireResponse, CliWirePrintable } +import std.process { ExitSuccess } + +// Operator reports retained independently of machine telemetry and slicer presets. A preset +// named PETG is not evidence that PETG was loaded; a generic Bambu profile is not the spool's +// manufacturer. These records neither identify a physical spool nor admit a print attempt. +// Sources are the operator's messages, recovered from session crisp-ibex-710 on 2026-10-03. +type PrinterSetupReportSource { session: NonEmptyStr, timestamp: NonEmptyStr } +type ReportedFilamentProduct { + packaging: NonEmptyStr + material: FilamentMaterial + source: PrinterSetupReportSource +} + +data creality_pla_report: ReportedFilamentProduct = ReportedFilamentProduct { + packaging: "Creality 2kg Black & White PLA 1.75mm Filament Bundle; two 1kg spools", + material: Pla, + source: PrinterSetupReportSource { + session: "crisp-ibex-710", timestamp: "2026-09-04T06:17:29.596Z", + }, +} + +type PrinterFilamentReport { + printer: PhysicalAssetIdentity + product: ReportedFilamentProduct + colour: NonEmptyStr + source: PrinterSetupReportSource +} + +data printer_colour_report_source: PrinterSetupReportSource = PrinterSetupReportSource { + session: "crisp-ibex-710", timestamp: "2026-09-05T05:58:48.735Z", +} + +data printer_filament_reports: List = [ + PrinterFilamentReport { printer: printer_01, product: creality_pla_report, + colour: "white", source: printer_colour_report_source }, + PrinterFilamentReport { printer: printer_02, product: creality_pla_report, + colour: "black", source: printer_colour_report_source }, +] + +// The slot report does not name a printer. Preserve that missing attribution instead of +// copying slot 1 to both machines, or treating the human slot number as an MQTT tray index. +type UnattributedPrinterSetupReport { statement: NonEmptyStr, source: PrinterSetupReportSource } +data ams_slot_report: UnattributedPrinterSetupReport = UnattributedPrinterSetupReport { + statement: "Filament loaded into AMS slot 1; printer not named in this message", + source: PrinterSetupReportSource { + session: "crisp-ibex-710", timestamp: "2026-09-04T06:15:41.511Z", + }, +} + +// The current operator says the setups are identical. This establishes a relationship, +// not a nozzle diameter, plate type, temperature, feedstock sensor state or clear bed. +type MatchingPrinterSetupsReport { + first: PhysicalAssetIdentity + second: PhysicalAssetIdentity + reported_on: NonEmptyStr + statement: NonEmptyStr +} +data matching_printer_setups_report: MatchingPrinterSetupsReport = MatchingPrinterSetupsReport { + first: printer_01, second: printer_02, reported_on: "2026-10-03", + statement: "both setups are identical", +} + +type PrinterFilamentReportLookup + = PrinterFilamentReported { report: PrinterFilamentReport } + | PrinterFilamentUnreported { printer: PhysicalAssetIdentity } + | PrinterFilamentReportsConflict { printer: PhysicalAssetIdentity } + +fn printer_filament_report(printer: PhysicalAssetIdentity) -> PrinterFilamentReportLookup { + let reports = filter(printer_filament_reports, r => r.printer == printer) + if length(reports) > 1 { + PrinterFilamentReportsConflict { printer: printer } + } else { + match reports |> first { + Present { value: r } => PrinterFilamentReported { report: r }, + Absent => PrinterFilamentUnreported { printer: printer }, + } + } +} + +// Read-only operator projection; no credential resolution or printer actuation. +fn main() -> CliWireResponse { + let rows = fold(printer_filament_reports, init: "", f: fn(text, r) { + concat(text, concat(r.printer as String, concat(": ", concat(r.colour as String, + concat("; ", concat(r.product.packaging as String, concat("; reported ", + concat(r.source.timestamp as String, "\n")))))))) + }) + CliWirePrintable { bytes: concat(rows, concat(ams_slot_report.statement as String, + concat("\n", concat(matching_printer_setups_report.statement as String, + " (operator report 2026-10-03).\nThese are setup reports, not live readiness or process qualification.\n")))), + exit: ExitSuccess } +} diff --git a/dag/gunbc/product/printed_chassis/fit_gauges.dag b/dag/gunbc/product/printed_chassis/fit_gauges.dag new file mode 100644 index 00000000000..bfe93241e47 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/fit_gauges.dag @@ -0,0 +1,99 @@ +module product.printed_chassis.fit_gauges + +import std.types { String, NonEmptyStr, List } +import extdeps.standards.micro_atx { + MountingLocationId, LocB, LocC, LocH, LocL, LocM, LocR, micro_atx_mounting_locations, +} +import extdeps.printing.bambu_lab_a1_mini { a1_mini_build_envelope } +import product.printed_chassis.standoff_gauge { + StandoffGaugeAdmission, GaugeAdmitted, GaugeLocationNotEnumerated, GaugeLocationDuplicated, + GaugeLocationsCoincide, GaugeExceedsBuildEnvelope, admit_standoff_gauge, + gauge_edge_margin, gauge_thickness, +} +import product.printed_chassis.cadquery_realization { + CadQueryEmitted, CadQueryDimensionUnrenderable, realize_plate_part, + plate_part_from_standoff_gauge, cadquery_program_source, +} +import product.printed_chassis.export_profile { exercised_stl_knobs } +import extdeps.cadquery.cadquery { stl_export_call } +import gunbc.cli_wire { CliWireResponse, CliWirePrintable } +import std.process { ExitSuccess, exit_failure } + +// Follow-on experiments to the existing F-C gauge. These five axis-aligned pairs connect +// the other five inferred board locations to that pair. J is explicitly excluded by the +// board authority and S is unobserved; neither earns a support by appearing in a standard. +// This roster selects experiments, not confirmed holes or structural support positions. +// The connected graph does not determine all relative angles: inspect against board edges +// and confirm the whole map in the adjustable fixture before freezing it. +// Each part uses the existing gauge admission, dimensions, build envelope and emitter. +type FitGaugePair { name: NonEmptyStr, a: MountingLocationId, b: MountingLocationId } + +data follow_on_fit_gauge_pairs: List = [ + FitGaugePair { name: "C-B", a: LocC, b: LocB }, + FitGaugePair { name: "C-H", a: LocC, b: LocH }, + FitGaugePair { name: "H-L", a: LocH, b: LocL }, + FitGaugePair { name: "L-M", a: LocL, b: LocM }, + FitGaugePair { name: "H-R", a: LocH, b: LocR }, +] + +fn fit_gauge_admission(pair: FitGaugePair) -> StandoffGaugeAdmission { + admit_standoff_gauge(rows: micro_atx_mounting_locations, envelope: a1_mini_build_envelope, + a: pair.a, b: pair.b, margin: gauge_edge_margin, thickness: gauge_thickness) +} + +// This generates a local experiment, not an absolute process-qualification receipt. +// The measured export knobs are reused without asserting that the unsettled profile is admitted. +// A refused experiment carries no executable source. +type FitGaugeSource = FitGaugeSourceReady { source: String } | FitGaugeSourceRefused { cause: NonEmptyStr } + +fn fit_gauge_source(pair: String) -> FitGaugeSource { + let matches = follow_on_fit_gauge_pairs |> filter(p => (p.name as String) == pair) + if (matches |> length) != 1 { + FitGaugeSourceRefused { cause: "expected exactly one declared fit gauge pair" } + } else { + match matches |> first { + Absent => FitGaugeSourceRefused { cause: "fit gauge pair absent" } + Present { value: p } => match fit_gauge_admission(pair: p) { + GaugeAdmitted { geometry: g } => match realize_plate_part(p: plate_part_from_standoff_gauge(g: g)) { + CadQueryEmitted { program: program } => FitGaugeSourceReady { source: concat( + cadquery_program_source(p: program) as String, + stl_export_call(destination: concat(p.name as String, ".stl"), knobs: exercised_stl_knobs)) }, + CadQueryDimensionUnrenderable { micrometres: _ } => FitGaugeSourceRefused { cause: "unrenderable gauge dimension" }, + }, + GaugeLocationNotEnumerated { requested: _ } => FitGaugeSourceRefused { cause: "gauge location absent" }, + GaugeLocationDuplicated { requested: _, count: _ } => FitGaugeSourceRefused { cause: "gauge location duplicated" }, + GaugeLocationsCoincide { requested: _ } => FitGaugeSourceRefused { cause: "gauge locations coincide" }, + GaugeExceedsBuildEnvelope { fit: _ } => FitGaugeSourceRefused { cause: "gauge exceeds build envelope" }, + }, + } + } +} + +// Machine-readable generated source, without terminal styling or any printer actuation. +fn main(pair: String) -> CliWireResponse { + fit_gauge_source_response(result: fit_gauge_source(pair: pair)) +} + +fn fit_gauge_source_response(result: FitGaugeSource) -> CliWireResponse { + match result { + FitGaugeSourceReady { source: s } => CliWirePrintable { bytes: s, exit: ExitSuccess }, + FitGaugeSourceRefused { cause: c } => CliWirePrintable { bytes: "", exit: exit_failure(reason: c) }, + } +} + +// One compiler load prepares the complete experiment set. Any refusal withholds the whole +// script, so a partial file cannot be mistaken for all five prepared gauges. +fn batch() -> CliWireResponse { + let source = fold(follow_on_fit_gauge_pairs, + init: FitGaugeSourceReady { source: "# Generated follow-on Altra fit gauges; experimental spacing checks.\n" }, + f: fn(acc, pair) { + match acc { + FitGaugeSourceRefused { cause: c } => FitGaugeSourceRefused { cause: c }, + FitGaugeSourceReady { source: previous } => match fit_gauge_source(pair: pair.name as String) { + FitGaugeSourceReady { source: next } => FitGaugeSourceReady { source: concat(previous, next) }, + FitGaugeSourceRefused { cause: c } => FitGaugeSourceRefused { cause: c }, + }, + } + }) + fit_gauge_source_response(result: source) +} diff --git a/dag/test/claim/fit_gauges_witness_test.dag b/dag/test/claim/fit_gauges_witness_test.dag new file mode 100644 index 00000000000..05f96d247a2 --- /dev/null +++ b/dag/test/claim/fit_gauges_witness_test.dag @@ -0,0 +1,35 @@ +module test.claim.fit_gauges_witness_test + +import std.types { Bool } +import product.printed_chassis.fit_gauges { follow_on_fit_gauge_pairs, fit_gauge_admission, fit_gauge_source, FitGaugeSourceReady, FitGaugeSourceRefused } +import product.printed_chassis.standoff_gauge { + GaugeAdmitted, GaugeLocationNotEnumerated, GaugeLocationDuplicated, + GaugeLocationsCoincide, GaugeExceedsBuildEnvelope, +} +import extdeps.standards.micro_atx { LocJ, LocS } + +test fn w_every_follow_on_gauge_fits_the_actual_printer_envelope() -> Bool { + (follow_on_fit_gauge_pairs |> length) > 0 && + fold(follow_on_fit_gauge_pairs, init: true, f: fn(ok, pair) { + ok && match fit_gauge_admission(pair: pair) { + GaugeAdmitted { geometry: _ } => true, + GaugeLocationNotEnumerated { requested: _ } => false, + GaugeLocationDuplicated { requested: _, count: _ } => false, + GaugeLocationsCoincide { requested: _ } => false, + GaugeExceedsBuildEnvelope { fit: _ } => false, + } + }) +} + +test fn w_no_follow_on_gauge_asks_for_an_excluded_or_unobserved_board_hole() -> Bool { + fold(follow_on_fit_gauge_pairs, init: true, f: fn(ok, pair) { + ok && pair.a != LocJ && pair.b != LocJ && pair.a != LocS && pair.b != LocS + }) +} + +test fn w_unknown_pair_cannot_emit_a_default_print() -> Bool { + match fit_gauge_source(pair: "C-J") { + FitGaugeSourceRefused { cause: _ } => true, + FitGaugeSourceReady { source: _ } => false, + } +} diff --git a/docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png b/docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png new file mode 100644 index 0000000000000000000000000000000000000000..1fc86f89410eb6f476ade996c9cb846bc2c8700a GIT binary patch literal 156177 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 A%m4rY literal 0 HcmV?d00001 diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index b0e3efd6fd6..31246d5a011 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1,6 +1,189 @@ # Printed node chassis — program plan -**Pinned plan. Updated 2026-09-02.** Printers land **2026-09-03**. +**Pinned plan. Progress reconciled 2026-10-03.** Printers are in use; the operator has the +printed standoff gauge in hand. The operator reports that the existing gauge aligns with numbered holes 1 and 3. + +## Current progress — 2026-10-03 + +Evidence: merged PRINT-N commit `b242faf7655` (#10680), current printed-chassis modules, +and the operator's 2026-10-03 report. Historical execution results are not tests rerun today. +This dated status supersedes the pre-arrival implementation narrative below. + +- **CAD bootstrap and measured export profile landed.** PRINT-N records CadQuery 2.8.0 running + through user-owned micromamba/conda-forge and measured tessellation observations in + `product.printed_chassis.export_profile`. The old kernel blocker is resolved. Full STEP/3MF + solid-readback acceptance is not established by that fact and remains to be audited. +- **Physical printing reached.** PRINT-N records printer-02 reporting `RUNNING` on the gauge + project after FTPS delivery and MQTT start through the modeled path. The operator now confirms + the gauge is in hand. No printer has been contacted in this session. +- **Coupon feedback recorded.** `hole_fit_measurement.operator_ladder_reading_2026_09_04` + records M3 interference / tight / free at 3.00 / 3.10 / 3.20 mm nominal rungs. Printer attribution + remains absent; this is not universal hole compensation or qualification of both machines. +- **Initial pair fit observed, 2026-10-03.** The operator reports that both gauge holes align + perfectly with numbered holes **1 and 3**, with the gauge underneath the motherboard and no + screws installed. The supplied annotated image identifies these as **F and C**. This is an + operator-reported alignment pass on that pair; it does not establish loaded support, exact + tolerances, the other holes or the full FIT milestone. One board is sufficient for this check. + +### Numbered reference and observed pair + +The repo already cited the operator's Drive image in +`extdeps.boards.asrock_rack.asrock_altrad8ud_standoff_figure_authority`. A byte-for-byte local copy +is now [ALTRAD8UD_standoff_grid.png](../extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png), retrieved +2026-10-03 from [the original Drive file](https://drive.google.com/file/d/1z36tmUWAwOwXWy6ASmaPWJNzKoZggBs8/view). +Use the image's numbers in operator instructions. Its annotated decimal coordinates are a visual +reference; they do not replace the standard's exact coordinate authority. + +| Image hole | Model location | +|---|---| +| 1 | F | +| 2 | M | +| 3 | C | +| 4 | H | +| 5 | L | +| 6 | B | +| 7 | R | + +**Observed:** 1–3 (F–C) aligns simultaneously underneath the board, without fasteners, per operator. +**Next candidate:** 3–4 (C–H). Other prepared checks are 4–5, 5–2, 3–6 and 4–7. +No new photo is required to identify the pair already reported against this reference. + +### Prepared follow-on experiments — 2026-10-03 + +`product.printed_chassis.fit_gauges` selects five additional gauges using the existing standard +location authority, gauge admission and CadQuery emitter. They are spacing experiments, not confirmed +mounting locations or qualified structural parts. Start with C–H after the existing F–C check passes; +the others extend coverage to the remaining inferred holes. Each is a separate part so it can be +identified by its file and labeled before leaving the bed. + +| Pair | Question answered | +|---|---| +| C–H | Does the rear-to-middle spacing agree on the C/H/L column? | +| H–L | Does the middle-to-front spacing agree on that column? | +| L–M | Does the front-row spacing agree? | +| C–B | Does the additional rear hole agree? | +| H–R | Does the outboard middle hole agree? | + +J remains excluded and S remains unobserved. A connected set of pair distances does not fix every +relative angle; the assembled adjustable fixture still must confirm the full map. Do not install +extra standoffs merely because the standard has a named location. + +**Measurements to gather before a mounting fixture:** + +- Identify the seven candidate holes (F, C, B, H, R, L, M) on one physical board; record any absent + or unexpected hole and pair mismatch. No need to disassemble several boards for this step. +- Actual standoff thread, height from support to board underside, and contact-pad/shoulder diameter; + identify the screws being used and their usable engagement. Do not infer these from the M3 coupon. +- Highest underside protrusion and clearance around each support; note solder joints/components + near the front overhang where a possible support would touch. +- Board edge dimensions and access around rear I/O, memory latches and power/storage connectors. + +**Measurements that can follow, before cassette geometry:** cooler model and total installed height; +PSU model, actual envelope and mounting points; fan model and intended fasteners; intended drives and +expansion cards; loaded node mass; cable connector bodies and the space needed to plug/unplug them; +available deployment width/depth/height. Missing values stay open instead of becoming defaults. The current fleet inventory identifies Dynatron W1 coolers on srv3 +and srv4; that does not identify the cooler on the board being checked, and its catalog row supplies +no measured installed envelope. + +**Recovered operator setup reports:** Creality PLA, white on printer-01 and black on printer-02, +reported September 4–5; on October 3 the operator says both setups are identical. These reports are +retained in `gunbc.fleet.printer_setup_reports`, with source timestamps and a read-only `main` +projection. The AMS slot-1 report does not name a printer, so it is not assigned to both machines. +Nozzle/plate settings still need reconciliation against retained execution evidence. Recovered files disagree: `print-n/work/gauge_petg.3mf` specifies PETG with +textured PEI; `printn-slice/gauge_container.3mf` specifies PLA with a cool plate. Preset metadata does not supersede the operator’s filament reports. Do not reuse their machine G-code blindly. Final slicing must also check +placement including brim/skirt: the longest narrow gauge nearly spans the 180 mm bed. Keep model +scale at 100%; resolve any placement conflict through explicit orientation/adhesion settings, not +by shrinking a measuring instrument. The existing fan-mount experiment +is optional after the actual fan and fasteners are identified; it does not gate motherboard FIT. + +### Preparation results — 2026-10-03 + +Generated **C–B, C–H, H–L, L–M and H–R** as individual STL and STEP files under +`/home/briansrls/print-prep-2026-10-03`. Generation ran through `fit_gauges.batch` with the +compiler built from this worktree; the generated Python contains the modeled geometry and export +settings. `geometry-readback.json` records hashes and executed results for all five parts: +valid single solids, expected dimensions and cylindrical hole locations/radii, STEP round-trip +agreement, closed manifold meshes, mesh bounds and volume agreement. These checks establish file +conformance for these gauges only, not physical fit or printer/material qualification. + +The three `test.claim.fit_gauges_witness_test` checks executed and passed: every candidate fits +the actual modeled printer envelope; none requests J/S; an unknown pair cannot emit a substitute. +`witnesses.log` retains the run. No physical observation has been inferred from those software tests; the 1–3 fit is separately +recorded from the operator above. + +**Ready for final slicing, not yet machine-ready G-code:** use C–H as the next candidate after the +existing F–C result. Use the recovered PLA reports; reconcile nozzle, plate and adhesion settings before +slicing. No upload or printer start was performed. Authenticated delivery/start was not revalidated; +the old `printer_credential_migration_run` includes destructive secret migration and is not an +appropriate routine readiness probe. A subsequent print must use an admitted credential path rather +than rerun that migration merely because it previously started a job. + +### Requested two-printer batch — 2026-10-03 + +The operator requests concurrent printing on both printers. Prepare **printer-01: holes 3–4 +(C–H)** and **printer-02: holes 4–5 (H–L)**. This checks two additional board spans; because the +parts differ, it is not a printer-to-printer calibration comparison. Source-identical STL copies and +hashes are staged under `/home/briansrls/print-prep-2026-10-03/two-printer-batch/`, one directory per +printer. Material/colour reports are now modeled: Creality PLA, white on printer-01 and black on +printer-02. Physical spool identity and current bed-clear state are not established by those reports. +Nozzle/plate reconciliation and slicing are pending. +The routine authenticated print entry point is also outstanding, as recorded above. No printer +start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. + +### Local preparation environment + +The isolated worktree is `/home/briansrls/.worktrees/gunbc/3d-printing-2026-10-03`. +Local preparation artifacts are in `/home/briansrls/print-prep-2026-10-03`: + +- `venv/`: CadQuery 2.8.0; resolved dependency versions in `requirements.lock`. +- `target/debug/gunbc`: compiler built from this worktree. Invocation requires an enforced cgroup + memory budget; the preparation run uses a systemd scope with `MemoryMax=24G` and no swap. +- `mounting-check-map.svg`: schematic from the checked-out standard-location rows; all physical + correspondences remain subject to the bench check. +- `verify-generated-gauges.py`: reads the emitted literal geometry instructions, runs them in + CadQuery, checks the resulting solid and STEP round-trip dimensions/hole axes/volume, and checks + STL bounds, closed manifold edges and volume. Its result only concerns generated-file conformance. +- `slicer-probe/DO_NOT_PRINT_old_settings.3mf`: tool-recovery probe using the old project settings; + deliberately excluded from the next-print candidates. + +The preserved OrcaSlicer 2.4.2 AppImage at `/tmp/orca.AppImage` matches the digest recorded in +`extdeps.printing.orca_slicer`. Its extracted CLI at `/tmp/orcax/squashfs-root/AppRun` now launches +after installing its missing OpenGL/WebKitGTK runtime dependencies. `--help` and model inspection +work. The upstream CLI refuses `--version` (despite the current modeled probe naming it); use the +recorded help output as this session's executable observation, not a claimed successful version probe. +The model's sealed positive Orca capability remains uninhabited; these local tool observations do +not claim to repair that admission path. Final slicing and printer actuation are separate outstanding +steps after the operator supplies current setup and gauge results. + +### Gauge observations to collect today + +Record each board separately: stable label, model/revision, selected holes and gauge orientation. +The gauge checks **LocF–LocC**, modeled as offsets of **157.48 mm along the rear edge and +22.86 mm in depth**. These are model values, not measured dimensions of the printed part. +Establish the physical hole correspondence before interpreting a result; say whether checking board +holes directly or installed standoffs, since the latter also tests standoff placement. + +Report whether both locations align simultaneously with the gauge resting naturally; if not, +which end aligns and the direction of the miss. Note obstructions or warp. Do not count alignment +obtained by bending the gauge or drawing it into place with screws as a pass. Record measured +values only when actually measured, with the instrument used. A photo can supplement the result. +An F–C pass supports that pair on that board, not the full mounting map or underside clearance. + +### Next steps and dependencies + +| Order | Work | Dependency / completion evidence | +|---|---|---| +| 1 | Record each board's F–C result | Operator observations; retain disagreements and distinguish board-hole alignment from standoff fit | +| 2 | Resolve misses; extend successful checks to remaining mounting locations | For a miss, distinguish orientation, printed-gauge dimensions and board/standoff placement before changing geometry. A pass permits additional pair gauges or an adjustable fixture, not freezing the entire map | +| 3 | Audit CAD, slicing and print evidence | Can proceed during board checks: locate generated artifacts, profile/toolchain identity, printer/material/nozzle binding and outstanding solid-readback controls; close PRINT-7/8 only against their full criteria | +| 4 | Complete process observations | Attribute the existing coupon only from retained evidence; otherwise print labeled replacements. Observe both printer/spool combinations independently and repeat attempts before claiming repeatability | +| 5 | Build an unpowered adjustable fit fixture | Confirm remaining mounting positions, standoff thread/height, underside keep-outs and connector access. PLA can serve this experiment without qualifying structural or powered use | +| 6 | Measure cassette inputs and choose joints/material | Can start now: actual cooler and PSU envelopes, node mass, drive requirement, fasteners, connector access and cable service space. Joint family and structural material precede their qualification prints | +| 7 | Prototype one removable cassette and one fixed bay | Requires fit and structural-process evidence; then PSU/cables, airflow and powered thermal validation under standing requirements, followed by 2x2 service demonstration | + +The immediate milestone is a verified mounting map and an unpowered fit fixture. Full cassette +geometry depends on measured hardware envelopes and structural-process qualification. + Governing statement: @@ -58,11 +241,11 @@ because a step without one cannot be said to be done. | **PRINT-3** | CAL | Coupon authority: ladder as modeled experimental design, rungs and plate derived | **done** | | **PRINT-4** | TOOLCHAIN | Realization contract v0: contract derives its own rungs; a wrong-step handler is caught and located | **done** | | **PRINT-5** | TOOLCHAIN | Raw transport schema admission: unknown version, missing field and unknown operation all refuse before any geometry call | **done** | -| **PRINT-6** | TOOLCHAIN | CadQuery handler + authority wall; sealed instruction plan; walls 1-3 execute, wall 4 (solid readback) is a declared boundary | handler done; materializer + export profile open | -| **PRINT-7** | TOOLCHAIN | STEP/3MF emitted and re-inspected; output conformance re-establishes envelope, holes, walls | | -| **PRINT-8** | TOOLCHAIN | Slicer profile bound or refused; per-printer manifests; ProcessQualificationIdentity minted, not branded | | -| **PRINT-9** | CAL | Coupons printed on BOTH printers; each printer+spool admitted or refused **independently** | needs printers | -| **PRINT-10** | FIT | Adjustable-standoff fixture; real board mounted unpowered; hole map measured back and frozen | needs printers + board | +| **PRINT-6** | TOOLCHAIN | CadQuery handler + authority wall; sealed instruction plan; walls 1-3 execute, wall 4 (solid readback) is a declared boundary | handler/export profile landed; full readback closure unverified | +| **PRINT-7** | TOOLCHAIN | STEP/3MF emitted and re-inspected; output conformance re-establishes envelope, holes, walls | kernel blocker resolved; full acceptance to verify | +| **PRINT-8** | TOOLCHAIN | Slicer profile bound or refused; per-printer manifests; ProcessQualificationIdentity minted, not branded | live printing exercised; qualification closure to verify | +| **PRINT-9** | CAL | Coupons printed on BOTH printers; each printer+spool admitted or refused **independently** | coupon feedback recorded; independent qualification and repeatability pending | +| **PRINT-10** | FIT | Adjustable-standoff fixture; real board mounted unpowered; hole map measured back and frozen | gauge in hand; 1–3 (F–C) alignment pass reported; full fixture/map open | | **PRINT-11** | CASSETTE | Structural cassette: rails, tray, handle, latch; carries node mass; no seam in layer-separation tension | needs PETG decision | | **PRINT-12** | CASSETTE | PSU carrier and harnesses; every connector insertable; nothing side-loaded; **PE continuous with board, standoffs and cassette all removed** | needs PSU envelope | | **PRINT-13** | CASSETTE | 80 mm fan carrier + replaceable duct; powered thermal admitted against a bench baseline | needs cooler choice | @@ -92,23 +275,18 @@ nominal. The check was run because the module's own annotation asserted "changin moves that witness", and an annotation asserting a property the code does not have is exactly what #10119 had to repair four times. -PRINT-5 and PRINT-6 landed with #10119, so the pre-arrival critical path is now **PRINT-7 alone**, -and it is BLOCKED rather than merely pending: re-inspecting an emitted STEP/3MF needs a CAD kernel, -and the trigger for that is a base image with glibc >= 2.38 or an x86_64 runner. PRINT-8 is the first -step that needs the hardware itself, and it is not blocked by PRINT-7 — the coupon can be printed and -measured before any kernel readback exists, it just cannot issue an absolute process-qualification -receipt until PRINT-7 closes. +PRINT-5 and PRINT-6 landed with #10119. PRINT-N (#10680) subsequently resolved CAD bootstrap, +added measured export-profile work, and exercised physical printing. PRINT-7/8 still require an +evidence audit against their full criteria; job-start evidence alone does not close them. ### What blocks what -- **PRINT-5..6 are landed.** PRINT-7 is BLOCKED on a CAD kernel — measured, not assumed: CadQuery - installs on this arm64 container and clears `libGL.so.1`, then `casadi` requires `GLIBCXX_3.4.32` - which requires `GLIBC_2.38`, and this box is glibc 2.36. The trigger is a base image or an x86_64 - runner, NOT a pip install. +- **CAD bootstrap is resolved.** Full output-conformance and process-qualification evidence + remain to be reconciled. - **Joint family** (dovetail / tongue-and-groove / keyed slide / pin) gates the ProductionInterfaceCoupon only — a later half of PRINT-8, not the MachineProcessCoupon. -- **PETG** gates PRINT-10 onward. PLA carries PRINT-8 and PRINT-9 and stops there: no PLA-to-PETG - receipt carry. +- **Structural material qualification** gates durable/powered cassette work. PLA can carry CAL + and unpowered FIT; no PLA-to-PETG receipt carry. - **Deployment envelope** gates PRINT-13..14 layout admission, nothing earlier. - **Drive count** gates PRINT-11 completion only. @@ -117,9 +295,9 @@ receipt until PRINT-7 closes. | Project | Scope | Terminal evidence | State | |---|---|---|---| | **CONTRACT** | Goals, non-goals, authority graph, refusal law, MVP boundary | No assumption represented as a default | deferred — no consumer yet | -| **MEASURE** | Board, cooler, PSU, fan, DIMM, cable, **deployment envelope**, uncertainty | Pack sufficient to generate CAL+FIT | **partial — carrier landed, roster empty** | -| **TOOLCHAIN** | Realization contract, CadQuery handler, authority wall, STEP/3MF, slicer env, printer nodes | Deterministic generation + executed no-parallel-authority controls | not started | -| **CAL+FIT** | Coupons, qualified tolerances, adjustable standoff fixture, real-board fit, hole-map feedback | One unpowered board fits; hole authority admitted | not started | +| **MEASURE** | Board, cooler, PSU, fan, DIMM, cable, **deployment envelope**, uncertainty | Pack sufficient to generate CAL+FIT | **partial — coupon readings recorded; board-fit observations pending** | +| **TOOLCHAIN** | Realization contract, CadQuery handler, authority wall, STEP/3MF, slicer env, printer nodes | Deterministic generation + executed no-parallel-authority controls | partial — CAD/export and live printing exercised; full conformance open | +| **CAL+FIT** | Coupons, qualified tolerances, adjustable standoff fixture, real-board fit, hole-map feedback | One unpowered board fits; hole authority admitted | in progress — coupon feedback and gauge in hand | | **CASSETTE** | Structure -> PSU/cables -> airflow -> powered thermal | Removable node operates and is serviceable | not started | | **RACK+VERDICT** | One bay -> 2x2 block -> scale/economics | Middle-node service proven; buy-more-printers verdict | not started | @@ -356,7 +534,7 @@ unwalled**: a `CommitRequired` artifact whose path matches a developer ignore pa uncommittable. Its trigger is the first such artifact; walling it before one exists would be a check whose RED is unauthorable. -**Export is removed from the emitted program rather than profiled.** The +**Historical pre-PRINT-N state; the measured export profile subsequently landed.** Export was removed from the emitted program rather than profiled. The `cq.exporters.export(result, "…stl")` line chose five things the model never stated — STL over STEP or 3MF, a filename, a working-directory-relative destination, format-by-extension, and CadQuery's default tessellation policy. The last is not neutral: STL is a mesh format, so every modeled circle @@ -382,7 +560,7 @@ on the grounds that it preserves a typed place for the next obligation, but noth gate required emptiness — so the forcing did not exist, and the discharged property is carried where it executes, in the type of the accepting arm. -**Toolchain boundary, now measured rather than assumed.** Wall 4 — re-reading the produced *solid* +**Historical bootstrap probe, superseded by PRINT-N conda-forge bootstrap.** Wall 4 — re-reading the produced *solid* for plate dimensions, datum location and the eleven ladder holes — is a §4b boundary obligation, not a rung. Its trigger was written as "a runner with cadquery importable", which was too vague to act on, so it was probed to termination: @@ -396,7 +574,7 @@ on, so it was probed to termination: - Past that, `casadi` requires **`GLIBCXX_3.4.32`**, and a libstdc++ carrying it requires **`GLIBC_2.38`**. This container is **glibc 2.36**. -So the real trigger is **a base image with glibc ≥ 2.38 (or an x86_64 runner), not a pip install** — +The probe then incorrectly concluded the trigger was **a base image with glibc ≥ 2.38 (or an x86_64 runner), not a pip install** — and assembling one by hand-extracting `.deb`s onto `LD_LIBRARY_PATH` is where a probe turns into the workaround §5 names, so it was stopped there rather than pushed through. The environment wall 4 needs is a *pinned, identified* toolchain — Python version, CadQuery version, OCP closure, image digest, @@ -492,20 +670,18 @@ So the PETG decision blocks the **durable/powered CASSETTE path**, not the first unpowered FIT fixture. Qualify each printer-spool combination independently; never infer that an observed difference is the printer alone. -## Open decisions (operator) — none block the first print +## Open decisions (operator) — structural and rack work -- **Drive count** — a CASSETTE completion obligation. Blocks nothing before Thursday. +- **Drive count** — a CASSETTE completion obligation. Does not block gauge checks or the unpowered fixture. - **PETG** — blocks structural/powered parts, not CAL or unpowered FIT. - **Deployment envelope** — **not on the pre-arrival critical path.** Its absence must refuse rack-layout admission later, not block calibration now. ## Next -1. ~~`CalibrationCouponAuthority`~~ — landed as `product.printed_chassis.coupon`. -2. `RealizationContract v0` — the transport-only, schema-bound feature graph the handler consumes. -3. CadQuery handler + four-part wall executed over v0, with all four negative controls. -4. STEP/3MF generation and conformance; bound slicer profile or refusal. -5. Per-printer manifests for node 1 and node 2; measurement/admission form ready for results. +Follow the dated next-step table above. Board-specific gauge observations and remaining mounting +checks lead to the unpowered fixture; toolchain-evidence reconciliation and cassette measurements +can proceed independently. The former list predated the landed PRINT-N implementation. **Deferred as definition-only residue until each has a consumer:** `CONTRACT` (needs the generator), `DeploymentEnvelope` (needs the layout-comparison consumer). The design of both is pinned above. From ade2f6a6b429c5bf45d697d893a1c1312e36d661 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 20:48:30 +0000 Subject: [PATCH 02/24] Route printer jobs through ntfy-approved workload identity --- .github/workflows/fleet-converge.yml | 178 +++++++++++++++++- dag/extdeps/exec/command.dag | 1 + dag/extdeps/tools/mosquitto_pub.dag | 24 +++ dag/gunbc/auth/gcp_iam_converge.dag | 2 + dag/gunbc/ci/ci_spec.dag | 15 ++ dag/gunbc/fleet/fleet_converge_workflow.dag | 67 ++++++- dag/gunbc/fleet/fleet_printer_access.dag | 48 +++++ dag/gunbc/fleet/printer_federation.dag | 35 ++++ .../fleet/printer_federation_provision.dag | 30 +++ dag/gunbc/fleet/printer_host_tools.dag | 23 +++ dag/gunbc/fleet/printer_job_start.dag | 16 +- dag/gunbc/fleet/printer_project_delivery.dag | 16 +- dag/gunbc/fleet/printer_tool_readiness.dag | 19 ++ dag/gunbc/fleet/printer_workflow_artifact.dag | 6 + dag/gunbc/fleet/printer_workflow_run.dag | 150 +++++++++++++++ .../printer_workflow_binding_witness_test.dag | 78 ++++++++ .../claim/printer_workflow_witness_test.dag | 37 ++++ docs/plans/printed-chassis-program.md | 45 ++++- 18 files changed, 773 insertions(+), 17 deletions(-) create mode 100644 dag/gunbc/fleet/printer_federation.dag create mode 100644 dag/gunbc/fleet/printer_federation_provision.dag create mode 100644 dag/gunbc/fleet/printer_host_tools.dag create mode 100644 dag/gunbc/fleet/printer_tool_readiness.dag create mode 100644 dag/gunbc/fleet/printer_workflow_artifact.dag create mode 100644 dag/gunbc/fleet/printer_workflow_run.dag create mode 100644 dag/test/claim/printer_workflow_binding_witness_test.dag create mode 100644 dag/test/claim/printer_workflow_witness_test.dag diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index db80d7305ec..2ca28d73ebf 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,7 @@ on: mode: description: "plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; app_key_version_verify reads the gunbai-ci App private key at the EXACT Secret Manager version named by app_key_version, mints an installation token with it, and refuses unless GitHub accepts it and the key's rotation deadline has not passed -- no add, disable or destroy; runner_browser_toolchain_converge installs the declared Playwright/Chromium toolchain (apt host libraries as the administrator, digest-pinned node, Playwright and Chromium archives into the job user's root) on the selected host, which must be in the pool that runs the floor job, and refuses unless every digest, version and host library reads back and headless Chromium renders a local page; microvm_host_converge installs the cited Firecracker release on the selected host and reads the kvm grant back, refusing by name when the grant has not landed (the grant itself is applied by the full-host apply spine); microvm_network_apply stages the slot and host network files the model renders at the named expected_revision as root:root 0600 in a root-only directory over the fleet SSH edge as the host's ADMINISTRATOR, installs them with the modeled operations, reloads networkd, systemd-sysctl and the nft loader unit, and reads the ruleset back -- the job user is granted none of it, because install plus systemctl over content that principal can write is arbitrary root for any pull request; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; spark_wireless_link_converge reads the selected Spark's Wi-Fi power save (runtime via iw, persisted via the NetworkManager profile), link and kernel disconnect count, sets whichever realization differs from the declared intent, and refuses unless the readback decides Noop; spark_v41_checkpoint_materialize fetches the admitted published DeepSeek V4.1 files onto the selected Group A Spark (about 510 GB; spark_v41_row_store_encode encodes the eight Engram row stores from the verified shards on the selected Group A Spark and reads each store's sha256; spark_v41_row_store_readback reads those stores back at their header, first and last record and every rank seam, with the published source rows at the same rows, and writes nothing; spark_v41_engram_differential compares upstream's Engram lookup kernel with the design-B file-backed lookup over sampled real rows of every row store, byte for byte, and writes nothing; it states the requirement and refuses before fetching when the disk cannot hold it), publishes each only after its sha256 matches the manifest, leaves a present file with the right digest alone and refuses one with the wrong digest, and reads the storage-backed Engram spans from the verified shards; a transfer runs detached and a rerun reattaches; spark_v41_runtime_image_build PRODUCES the DeepSeek V4.1 image on the selected Spark -- it verifies the candidate's three FlashInfer wheels against the digests the candidate keys, converges the patched source tree, builds from it, reads the produced configuration digest back from inside the image through the probe route, and admits that digest against the candidate's own recipe, refusing a digest that does not recompute from it -- and it is a separate mode from the probe because it occupies one host for hours where the probe occupies it for minutes; spark_v41_runtime_image_distribute moves that produced image, named by the configuration digest its production receipt read back, from the host that receipt names to the selected Group A Spark -- save into its fabric blob root, pulled by the target straight over the fabric rail, load -- after stating its size against every filesystem a copy lands on, leaves a target already holding the digest untouched, refuses a target holding a different image under the tag, and refuses unless the target's image inspect Id reads back as that digest; spark_v41_group_a_launch reads the production image back under its tag on every Group A host, reads its registry inside its digest and every host's occupancy, and only when Group A is suspended for this candidate with every host held and vacant stages the Engram manifest and applies the V4.1 four-rank arm as one transaction at the capacity measurement's shape -- the target names only the session host; spark_v41_serving_load runs the shared serving-load runner against the V4.1 head (target must be srv6): one vllm bench serve step per capacity-measurement concurrency, metrics scraped around each, host pressure read on every Group A rank, and the staircase stop rules applied over the worst rank; it changes no unit and writes only its receipt; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown; microvm_controller_install writes the microVM slot controller's root-owned release locus (gunbc + sources + Firecracker + jailer) and the gunbc-microvm-slot@ template unit and the gunbc-microvm-slot-reserve broker unit on srv1 and starts neither; microvm_slot_reserve starts that broker unit once on srv1, which reserves the shakedown cell through the fabric broker route from inside the root process (slot, demand and offer derived from the model, never inputs), and uploads that invocation's reservation receipt, failing unless the reservation committed; microvm_slot_start starts the shakedown slot's controller unit once on srv1 (the instance is derived from the model, never an input), waits for it bounded by the unit's own stop timeout, and uploads that invocation's controller receipt; microvm_runner_group_ensure (srv1 only) reads the organization's runner groups and, only when the microvm-shakedown group is absent, files ONE operator approval, creates it restricted to the shakedown workflow on the default branch, and refuses unless the readback holds that restriction; mtcollins1_census_qemu_host_observe reads the selected host's KVM device and, under the job user's census QEMU root, the qemu-system-aarch64 build, its ldd libraries and the AAVMF images against their pins, and writes nothing; mtcollins1_census_qemu_toolchain_converge places that root as the job user (digest-pinned noble .debs unpacked with dpkg-deb -x, no apt, no Recommends) and refuses unless the same observe reads it ready" required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, microvm_network_apply, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, spark_wireless_link_converge, spark_v41_checkpoint_materialize, spark_v41_row_store_encode, spark_v41_row_store_readback, spark_v41_engram_differential, spark_v41_runtime_image_build, spark_v41_runtime_image_distribute, spark_v41_group_a_launch, spark_v41_serving_load, dashboard_deploy, approval_broker_dark_install, microvm_controller_install, microvm_slot_reserve, microvm_slot_start, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, runner_browser_toolchain_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, approval_device_enrolment_code_issue, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_ui_bundle_observe, mtcollins1_kvm_observer_observe, mtcollins1_census_image_publish, mtcollins1_census_member_readback, mtcollins1_census_qemu_host_observe, mtcollins1_census_qemu_toolchain_converge, host_credential_custody_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe, pair_serving_d0, microvm_runner_group_ensure, gcp_iam_converge, namecheap_observe] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, microvm_network_apply, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, spark_wireless_link_converge, spark_v41_checkpoint_materialize, spark_v41_row_store_encode, spark_v41_row_store_readback, spark_v41_engram_differential, spark_v41_runtime_image_build, spark_v41_runtime_image_distribute, spark_v41_group_a_launch, spark_v41_serving_load, dashboard_deploy, approval_broker_dark_install, microvm_controller_install, microvm_slot_reserve, microvm_slot_start, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, runner_browser_toolchain_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, approval_device_enrolment_code_issue, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_ui_bundle_observe, mtcollins1_kvm_observer_observe, mtcollins1_census_image_publish, mtcollins1_census_member_readback, mtcollins1_census_qemu_host_observe, mtcollins1_census_qemu_toolchain_converge, host_credential_custody_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe, pair_serving_d0, microvm_runner_group_ensure, gcp_iam_converge, namecheap_observe, printer] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -66,6 +66,33 @@ on: description: "org_runner_roster_observe only: page size for the first roster request (1..100, default 100); later pages follow the authority's own continuation links" required: false type: string + printer: + description: Printer identity; required for printer mode + required: false + options: [printer-01, printer-02] + type: choice + printer_operation: + description: Observe credential identity or start one pinned project + required: false + options: [observe, print] + type: choice + printer_project_path: + description: Absolute path of staged sliced project on srv1 + required: false + type: string + printer_project_sha256: + description: SHA-256 of the reviewed sliced project + required: false + type: string + printer_credential_version: + description: Numeric Secret Manager version returned by observation + required: false + type: string + printer_bed_clear: + description: Operator reports this printer bed is clear for this dispatch + required: false + options: ["false", "true"] + type: choice transaction_nonce: description: Operator-minted unique nonce for one RLM transaction; echoed into run-name so each dispatched run is API-selectable by its displayTitle, closing the run-id correlation gap (review 5062738052 B4). Not consumed by any step required: false @@ -84,7 +111,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Isolate toolchain dirs run: |- rm -rf "$RUNNER_TEMP/rustup" "$RUNNER_TEMP/cargo" @@ -391,7 +418,7 @@ jobs: runs-on: ${{ fromJSON(format('["self-hosted","linux","arm64","{0}"]', github.event.inputs.host)) }} needs: [build] timeout-minutes: 295 - if: github.event.inputs.mode != 'mtcollins1_boot' && github.event.inputs.mode != 'gcp_iam_converge' && github.event.inputs.mode != 'namecheap_observe' + if: github.event.inputs.mode != 'mtcollins1_boot' && github.event.inputs.mode != 'gcp_iam_converge' && github.event.inputs.mode != 'namecheap_observe' && github.event.inputs.mode != 'printer' permissions: contents: read actions: read @@ -404,7 +431,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -2201,7 +2228,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -2314,7 +2341,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -2448,7 +2475,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -2547,7 +2574,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -2650,7 +2677,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.sha }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe' || github.event.inputs.mode == 'printer') && github.sha || github.event.inputs.expected_revision || github.sha }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -3092,3 +3119,136 @@ jobs: compression-level: 0 if: success() timeout-minutes: 10 + printer: + runs-on: [self-hosted, linux, arm64, srv1] + needs: [build] + environment: printer-lan + timeout-minutes: 35 + if: github.event.inputs.mode == 'printer' + permissions: + contents: read + actions: read + id-token: write + concurrency: + group: printer-${{ inputs.printer }} + cancel-in-progress: false + steps: + - name: Checkout (the event sha only; no dispatch input selects these bytes) + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - name: Download release-bins artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: release-bins + path: ${{ runner.temp }}/release-bins-download + timeout-minutes: 10 + - name: Verify the handed-off release bins against the build key, then promote and run --verify-build-artifacts (fail-closed) + id: release_bins + run: | + set -e + ROOT=$('git' 'rev-parse' '--show-toplevel') + '[' '-n' "$RELEASE_BINS_KEY" ']' || ('echo' '::error title=release-bins ConsumerKeyMissing::the build job published no release-bins key'; exit 1) + '[' '-f' "$RUNNER_TEMP"'/release-bins-download/release-bins.preimage' ']' || ('echo' '::error title=release-bins ConsumerFileMissing::the release-bins artifact is missing release-bins.preimage'; exit 1) + '[' '-f' "$RUNNER_TEMP"'/release-bins-download/release-bins.manifest' ']' || ('echo' '::error title=release-bins ConsumerFileMissing::the release-bins artifact is missing release-bins.manifest'; exit 1) + '[' '-f' "$RUNNER_TEMP"'/release-bins-download/release-bins.tgz' ']' || ('echo' '::error title=release-bins ConsumerFileMissing::the release-bins artifact is missing release-bins.tgz'; exit 1) + 'sha256sum' "$RUNNER_TEMP"'/release-bins-download/release-bins.preimage' > "$RUNNER_TEMP"'/DOWNLOADED_DIGEST.out' + DOWNLOADED_DIGEST=$('cut' '-d' ' ' '-f' '1' "$RUNNER_TEMP"'/DOWNLOADED_DIGEST.out') + '[' 'release-bins-'"$DOWNLOADED_DIGEST" '=' "$RELEASE_BINS_KEY" ']' || ('echo' '::error title=release-bins ConsumerPreimageKeyMismatch::the downloaded preimage does not hash to the build job'\''s key'; exit 1) + OBJECT_FORMAT=$('git' '-C' "$ROOT" 'rev-parse' '--show-object-format') + SOURCE_TREE=$('git' '-C' "$ROOT" 'rev-parse' 'HEAD^{tree}') + '[' '-n' "$OBJECT_FORMAT" ']' || ('echo' '::error title=release-bins AxisValueUnread::the key input OBJECT_FORMAT read back empty, so the key would not name the build; refusing to key'; exit 1) + '[' '-n' "$SOURCE_TREE" ']' || ('echo' '::error title=release-bins AxisValueUnread::the key input SOURCE_TREE read back empty, so the key would not name the build; refusing to key'; exit 1) + SOURCE_CLOSURE='tree:'"$OBJECT_FORMAT"':'"$SOURCE_TREE" + 'grep' '-qxF' 'source_closure='"$SOURCE_CLOSURE" "$RUNNER_TEMP"'/release-bins-download/release-bins.preimage' || ('echo' '::error title=release-bins ConsumerSourceTreeMismatch::the pack was keyed on a different source tree than this job'\''s checkout'; exit 1) + 'printf' '%s' 'claim_executor + gunbc + discover_source_root_ingest + claim_batch + interp_recorded_fixture_witness + v1_src_dag_parse + auth_declared_but_unwired_witness + bootstrap_witness + dag_collect_fingerprint_witness + diagnostics_witness + effects_rest_transport_witness + infer_semantics_witness + parse_witness + cssl_assemble + namespace_structural_root_exposure_generated_witness + codex_app_server_stdio_session + ' > "$RUNNER_TEMP"'/release-bins.roster' + 'tar' '-tzf' "$RUNNER_TEMP"'/release-bins-download/release-bins.tgz' > "$RUNNER_TEMP"'/pack-listing.out' + 'cmp' '-s' "$RUNNER_TEMP"'/pack-listing.out' "$RUNNER_TEMP"'/release-bins.roster' || ('echo' '::error title=release-bins PackListingNotRoster::the downloaded pack'\''s archive listing is not exactly the roster (an extra, duplicate, directory or escaping entry)'; exit 1) + 'cut' '-c' '67-' "$RUNNER_TEMP"'/release-bins-download/release-bins.manifest' > "$RUNNER_TEMP"'/manifest-names.out' + 'cmp' '-s' "$RUNNER_TEMP"'/manifest-names.out' "$RUNNER_TEMP"'/release-bins.roster' || ('echo' '::error title=release-bins ManifestRosterMismatch::the downloaded manifest does not name exactly the gunbc.ci_release_bins roster in order'; exit 1) + if 'grep' '-qvE' '^[0-9a-f]{64} [A-Za-z0-9_]+$' "$RUNNER_TEMP"'/release-bins-download/release-bins.manifest'; then ('echo' '::error title=release-bins ManifestMalformed::the downloaded manifest has a line that is not one well-formed sha256 record for a roster member'; exit 1); fi + 'rm' '-rf' "$RUNNER_TEMP"'/release-bins-check' + 'mkdir' '-p' "$RUNNER_TEMP"'/release-bins-check' + 'tar' '-xzf' "$RUNNER_TEMP"'/release-bins-download/release-bins.tgz' '-C' "$RUNNER_TEMP"'/release-bins-check' '--no-same-owner' + 'printf' '%s' 'claim_executor:regular file:1 + gunbc:regular file:1 + discover_source_root_ingest:regular file:1 + claim_batch:regular file:1 + interp_recorded_fixture_witness:regular file:1 + v1_src_dag_parse:regular file:1 + auth_declared_but_unwired_witness:regular file:1 + bootstrap_witness:regular file:1 + dag_collect_fingerprint_witness:regular file:1 + diagnostics_witness:regular file:1 + effects_rest_transport_witness:regular file:1 + infer_semantics_witness:regular file:1 + parse_witness:regular file:1 + cssl_assemble:regular file:1 + namespace_structural_root_exposure_generated_witness:regular file:1 + codex_app_server_stdio_session:regular file:1 + ' > "$RUNNER_TEMP"'/member-types.expected' + ('cd' "$RUNNER_TEMP"'/release-bins-check' && 'stat' '-c' '%n:%F:%h' 'claim_executor' 'gunbc' 'discover_source_root_ingest' 'claim_batch' 'interp_recorded_fixture_witness' 'v1_src_dag_parse' 'auth_declared_but_unwired_witness' 'bootstrap_witness' 'dag_collect_fingerprint_witness' 'diagnostics_witness' 'effects_rest_transport_witness' 'infer_semantics_witness' 'parse_witness' 'cssl_assemble' 'namespace_structural_root_exposure_generated_witness' 'codex_app_server_stdio_session') > "$RUNNER_TEMP"'/member-types.out' + 'cmp' '-s' "$RUNNER_TEMP"'/member-types.out' "$RUNNER_TEMP"'/member-types.expected' || ('diff' "$RUNNER_TEMP"'/member-types.expected' "$RUNNER_TEMP"'/member-types.out' || ':' 'no-op'; ('echo' '::error title=release-bins MemberNotRegularFile::the downloaded pack has a member that is not a self-contained regular file (a link, special file or hard-link dependency; the report above names it)'; exit 1)) + ('cd' "$RUNNER_TEMP"'/release-bins-check' && 'find' '.' '-mindepth' '1' '-maxdepth' '1' '!' '-perm' '-u+x') > "$RUNNER_TEMP"'/member-nonexec.out' + '[' '!' '-s' "$RUNNER_TEMP"'/member-nonexec.out' ']' || ('cat' "$RUNNER_TEMP"'/member-nonexec.out'; ('echo' '::error title=release-bins MemberNotExecutable::the downloaded pack has a member that is not executable (listed above)'; exit 1)) + ('cd' "$RUNNER_TEMP"'/release-bins-check' && 'sha256sum' '--strict' '--quiet' '-c' "$RUNNER_TEMP"'/release-bins-download/release-bins.manifest') || ('echo' '::error title=release-bins MemberDigestMismatch::the downloaded member bytes do not match the manifest under strict checking; refusing before any member is promoted or run'; exit 1) + 'mkdir' '-p' "$ROOT"'/target/release' + ('cd' "$ROOT"'/target/release' && 'rm' '-f' 'claim_executor' 'gunbc' 'discover_source_root_ingest' 'claim_batch' 'interp_recorded_fixture_witness' 'v1_src_dag_parse' 'auth_declared_but_unwired_witness' 'bootstrap_witness' 'dag_collect_fingerprint_witness' 'diagnostics_witness' 'effects_rest_transport_witness' 'infer_semantics_witness' 'parse_witness' 'cssl_assemble' 'namespace_structural_root_exposure_generated_witness' 'codex_app_server_stdio_session') + ('cd' "$RUNNER_TEMP"'/release-bins-check' && 'cp' '-p' 'claim_executor' 'gunbc' 'discover_source_root_ingest' 'claim_batch' 'interp_recorded_fixture_witness' 'v1_src_dag_parse' 'auth_declared_but_unwired_witness' 'bootstrap_witness' 'dag_collect_fingerprint_witness' 'diagnostics_witness' 'effects_rest_transport_witness' 'infer_semantics_witness' 'parse_witness' 'cssl_assemble' 'namespace_structural_root_exposure_generated_witness' 'codex_app_server_stdio_session' "$ROOT"'/target/release/') + 'rm' '-rf' "$RUNNER_TEMP"'/release-bins-check' + "$ROOT"'/target/release/claim_executor' '--verify-build-artifacts' "$ROOT"'/target/release/claim_executor' "$ROOT"'/target/release/gunbc' "$ROOT"'/target/release/discover_source_root_ingest' "$ROOT"'/target/release/claim_batch' "$ROOT"'/target/release/interp_recorded_fixture_witness' "$ROOT"'/target/release/v1_src_dag_parse' "$ROOT"'/target/release/auth_declared_but_unwired_witness' "$ROOT"'/target/release/bootstrap_witness' "$ROOT"'/target/release/dag_collect_fingerprint_witness' "$ROOT"'/target/release/diagnostics_witness' "$ROOT"'/target/release/effects_rest_transport_witness' "$ROOT"'/target/release/infer_semantics_witness' "$ROOT"'/target/release/parse_witness' "$ROOT"'/target/release/cssl_assemble' "$ROOT"'/target/release/namespace_structural_root_exposure_generated_witness' "$ROOT"'/target/release/codex_app_server_stdio_session' + env: + RELEASE_BINS_KEY: ${{ needs.build.outputs.release_bins_key }} + timeout-minutes: 5 + - name: WIF auth (dedicated printer credential reader) + id: wif_auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + workload_identity_provider: projects/582015116396/locations/global/workloadIdentityPools/github-printer-lan/providers/github-printer-lan-oidc + service_account: printer-lan@gunbai-secrets.iam.gserviceaccount.com + token_format: access_token + create_credentials_file: false + timeout-minutes: 5 + - name: "Printer: observe credential or deliver and start the pinned project" + id: printer + run: |- + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/fleet/printer_workflow_run.dag --function run + cat "$ROOT/printer-workflow-receipt.json" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + PRINTER_TARGET: ${{ inputs.printer }} + PRINTER_OPERATION: ${{ inputs.printer_operation }} + PRINTER_PROJECT_PATH: ${{ inputs.printer_project_path }} + PRINTER_PROJECT_SHA256: ${{ inputs.printer_project_sha256 }} + PRINTER_CREDENTIAL_VERSION: ${{ inputs.printer_credential_version }} + PRINTER_BED_CLEAR: ${{ inputs.printer_bed_clear }} + timeout-minutes: 15 + - name: Upload printer operation receipt + id: printer_receipt + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: printer-operation + path: printer-workflow-receipt.json + if-no-files-found: error + retention-days: 90 + compression-level: 0 + if: success() + timeout-minutes: 10 diff --git a/dag/extdeps/exec/command.dag b/dag/extdeps/exec/command.dag index ee94b1e084d..3916c630903 100644 --- a/dag/extdeps/exec/command.dag +++ b/dag/extdeps/exec/command.dag @@ -173,6 +173,7 @@ fn argv_command(program: ProgramIdentity, arguments: List) -> ArgvComman decl_ref(module_path: "extdeps.tools.nbdkit", decl_name: "nbdkit_readonly_file_serve_command"), decl_ref(module_path: "extdeps.tools.websocat", decl_name: "websocat_nbd_proxy_bridge_command"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_publish_command"), + decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_pub_help_command"), decl_ref(module_path: "extdeps.tools.gnu_time", decl_name: "gnu_time_max_rss_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_version_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_user_manager_probe_command"), diff --git a/dag/extdeps/tools/mosquitto_pub.dag b/dag/extdeps/tools/mosquitto_pub.dag index 6eb4dd704f3..46500464945 100644 --- a/dag/extdeps/tools/mosquitto_pub.dag +++ b/dag/extdeps/tools/mosquitto_pub.dag @@ -123,3 +123,27 @@ fn mosquitto_publish_command( ], ) } + +// The upstream synopsis exposes --help, not --version. Its first line identifies +// the client version; publishing is never used as a tool-availability probe. +fn mosquitto_pub_help_command() -> ArgvCommand { + argv_command(program: mosquitto_pub_program(), arguments: ["--help"]) +} + +fn mosquitto_pub_help_supports_private_options(stdout: String) -> Bool { + let prefix = "mosquitto_pub version " + if !starts_with(s: stdout, prefix: prefix) { false } + else { + match split(s: stdout, delimiter: " ") |> skip(n: 2) |> first { + Absent => false + Present { value: version } => + match split(s: version, delimiter: ".") |> first { + Absent => false + Present { value: major } => match parse_int(s: major) { + Absent => false + Present { value: n } => n >= 2 + } + } + } + } +} diff --git a/dag/gunbc/auth/gcp_iam_converge.dag b/dag/gunbc/auth/gcp_iam_converge.dag index e52e331ef9f..81af3f02fdb 100644 --- a/dag/gunbc/auth/gcp_iam_converge.dag +++ b/dag/gunbc/auth/gcp_iam_converge.dag @@ -25,6 +25,7 @@ import gunbc.auth.heal_publisher_provision { heal_publisher_dedicated_federation, private_heal_publisher_dedicated_federation, impersonation_cell, observe_and_classify, rest_ok, rest_refusal_text, outcome_is_not_found, } +import gunbc.fleet.printer_federation_provision { printer_dedicated_federation } import gunbc.namecheap.federation_provision { namecheap_dedicated_federation } import gunbc.auth.mtcollins1_boot_federation_provision { mtcollins1_boot_dedicated_federation } import gunbc.auth.mtcollins1_boot_federation { pinned_version_accessor_grant_for } @@ -214,6 +215,7 @@ data gcp_iam_converge_targets: List = [ private_heal_publisher_dedicated_federation(), mtcollins1_boot_dedicated_federation(), namecheap_dedicated_federation(), + printer_dedicated_federation(), ] fn service_account_resource(email: ServiceAccountEmail) -> String { diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 37fc62653eb..9df24a8a787 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -1,5 +1,7 @@ module gunbc.ci_spec +import gunbc.fleet.printer_workflow_artifact { printer_workflow_receipt_path } + import gunbc.machine_intake_mtcollins1_kvm_still { mtcollins1_kvm_observer_dir } import gunbc.namecheap.observation_artifact { namecheap_observation_receipt_path } import gunbc.runner_microvm_slot_unit { microvm_slot_start_receipt_path, microvm_slot_reserve_receipt_path } @@ -883,6 +885,11 @@ data gunbc_ci_runner_browser_toolchain_converge_target: GunbcRunStepTarget = Gun function: "runner_browser_toolchain_converge_ci_wet", } +data gunbc_ci_printer_workflow_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/fleet/printer_workflow_run.dag", + function: "run", +} + data gunbc_ci_namecheap_observe_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/namecheap/observe.dag", function: "namecheap_observe_ci_wet", @@ -1318,6 +1325,7 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_runner_browser_toolchain_converge_target, gunbc_ci_host_credential_custody_converge_target, gunbc_ci_namecheap_observe_target, + gunbc_ci_printer_workflow_target, gunbc_ci_runner_microvm_boot_probe_target, gunbc_ci_microvm_slot_start_target, gunbc_ci_microvm_slot_reserve_target, @@ -3823,3 +3831,10 @@ fn gunbc_ci_namecheap_observe_invoke() -> String { receipt_rel: namecheap_observation_receipt_path ) } + +fn gunbc_ci_printer_workflow_invoke() -> String { + gunbc_run_step_script(source_roots: witness_layer_roots, + entry: gunbc_ci_printer_workflow_target.entry, + function: gunbc_ci_printer_workflow_target.function, + claim_run: false, receipt_rel: printer_workflow_receipt_path) +} diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index d76dcc357a9..41143344b12 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -1,10 +1,14 @@ module gunbc.fleet_converge_workflow +import gunbc.fleet.printer_workflow_artifact { printer_workflow_receipt_path } + import extdeps.github.expressions { Expression, ContextAccess, Github, StringLiteral, Equals, LogicalOr, LogicalAnd, interpolate } import gunbc.namecheap.observation_artifact { namecheap_observation_receipt_path } +import gunbc.fleet.printer_federation { printer_environment, printer_provider_resource, printer_service_account } +import gunbc.ci_spec { gunbc_ci_printer_workflow_invoke } import gunbc.namecheap.federation { namecheap_environment, namecheap_provider_resource, namecheap_service_account } import gunbc.ci_spec { gunbc_ci_namecheap_observe_invoke, gunbc_ci_sm_max_time } @@ -324,6 +328,7 @@ type FleetConvergeWorkflowMode | OrgRunnerRosterObserve | GcpIamConverge | NamecheapObserve + | PrinterWorkflow fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String { match mode { @@ -377,6 +382,7 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String MtCollins1Boot => "mtcollins1_boot" GcpIamConverge => "gcp_iam_converge" NamecheapObserve => "namecheap_observe" + PrinterWorkflow => "printer" PairServingD0 => "pair_serving_d0" MtCollins1FanObserve => "mtcollins1_fan_observe" MtCollins1UiBundleObserve => "mtcollins1_ui_bundle_observe" @@ -412,7 +418,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, MicrovmNetworkApply, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, SparkWirelessLinkConverge, SparkV41CheckpointMaterialize, SparkV41RowStoreEncode, SparkV41RowStoreReadback, SparkV41EngramDifferential, SparkV41RuntimeImageBuild, SparkV41RuntimeImageDistribute, SparkV41GroupALaunch, SparkV41ServingLoad, DashboardDeploy, ApprovalBrokerDarkInstall, MicrovmControllerInstall, MicrovmSlotReserve, MicrovmSlotStart, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, RunnerBrowserToolchainConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, ApprovalDeviceEnrolmentCodeIssue, MtCollins1Boot, MtCollins1FanObserve, MtCollins1UiBundleObserve, MtCollins1KvmObserverObserve, MtCollins1CensusImagePublish, MtCollins1CensusMemberReadback, MtCollins1CensusQemuHostObserve, MtCollins1CensusQemuToolchainConverge, HostCredentialCustodyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve, PairServingD0, MicrovmRunnerGroupEnsure, GcpIamConverge, NamecheapObserve] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, MicrovmNetworkApply, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, SparkWirelessLinkConverge, SparkV41CheckpointMaterialize, SparkV41RowStoreEncode, SparkV41RowStoreReadback, SparkV41EngramDifferential, SparkV41RuntimeImageBuild, SparkV41RuntimeImageDistribute, SparkV41GroupALaunch, SparkV41ServingLoad, DashboardDeploy, ApprovalBrokerDarkInstall, MicrovmControllerInstall, MicrovmSlotReserve, MicrovmSlotStart, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, RunnerBrowserToolchainConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, ApprovalDeviceEnrolmentCodeIssue, MtCollins1Boot, MtCollins1FanObserve, MtCollins1UiBundleObserve, MtCollins1KvmObserverObserve, MtCollins1CensusImagePublish, MtCollins1CensusMemberReadback, MtCollins1CensusQemuHostObserve, MtCollins1CensusQemuToolchainConverge, HostCredentialCustodyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve, PairServingD0, MicrovmRunnerGroupEnsure, GcpIamConverge, NamecheapObserve, PrinterWorkflow] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // // EVERY MODE IS NAMED, AND THE WILDCARD IS DELIBERATELY ABSENT. A `_ => none` would read the same @@ -485,6 +491,7 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc MtCollins1Boot => none GcpIamConverge => none NamecheapObserve => none + PrinterWorkflow => none PairServingD0 => none MtCollins1FanObserve => none MtCollins1UiBundleObserve => none @@ -631,6 +638,7 @@ fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> MicrovmRunnerGroupEnsure => FleetSshKeyNotConsumed GcpIamConverge => FleetSshKeyNotConsumed NamecheapObserve => FleetSshKeyNotConsumed + PrinterWorkflow => FleetSshKeyNotConsumed } } @@ -1266,6 +1274,7 @@ fn fleet_converge_mode_mutation_domain(mode: FleetConvergeWorkflowMode) -> Fleet OrgRunnerRosterObserve => ExecutorDomain GcpIamConverge => ExecutorDomain NamecheapObserve => ExecutorDomain + PrinterWorkflow => ExecutorDomain } } @@ -3535,7 +3544,7 @@ data fleet_converge_checkout_ref: Expression = LogicalOr { left: LogicalAnd { left: LogicalOr { left: LogicalOr { left: fleet_converge_dispatch_mode_is(mode: MtCollins1Boot), right: fleet_converge_dispatch_mode_is(mode: GcpIamConverge) }, - right: fleet_converge_dispatch_mode_is(mode: NamecheapObserve), + right: LogicalOr { left: fleet_converge_dispatch_mode_is(mode: NamecheapObserve), right: fleet_converge_dispatch_mode_is(mode: PrinterWorkflow) }, }, right: fleet_converge_event_sha, }, @@ -3603,6 +3612,7 @@ data fleet_converge_shared_job_if: String = join([ "github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: MtCollins1Boot), "'", " && github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: GcpIamConverge), "'", " && github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: NamecheapObserve), "'", + " && github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: PrinterWorkflow), "'", ], "") // THE RUNNER AND THE CONCURRENCY DOMAIN ARE FIXED TO THE STORE HOST, never read from the dispatch's @@ -3997,6 +4007,7 @@ fn fleet_converge_mode_job_id(mode: FleetConvergeWorkflowMode) -> NonEmptyStr { MicrovmRunnerGroupEnsure => fleet_converge_shared_job_id GcpIamConverge => "gcp-iam-converge" as NonEmptyStr NamecheapObserve => "namecheap-observe" as NonEmptyStr + PrinterWorkflow => "printer" as NonEmptyStr } } @@ -4030,6 +4041,7 @@ data fleet_converge_job_edges: List = [ FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: ApprovalBrokerDarkInstall), needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: MicrovmControllerInstall), needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: RlmLaunchDeploymentReceipt), needs: [fleet_converge_release_bins_job_id as String] }, + FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: PrinterWorkflow), needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: NamecheapObserve), needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: fleet_converge_mode_job_id(mode: GcpIamConverge), needs: [fleet_converge_release_bins_job_id as String] }, ] @@ -4069,6 +4081,7 @@ fn fleet_converge_jobs() -> List { fleet_converge_rlm_launch_deployment_receipt_job(), fleet_converge_gcp_iam_converge_job(), fleet_converge_namecheap_observe_job(), + fleet_converge_printer_job(), ] } @@ -4289,6 +4302,12 @@ data fleet_converge_dispatch_inputs: List = [ default: none, type: InputString, }, + DispatchInput { name: "printer", description: Present { value: "Printer identity; required for printer mode" }, required: false, default: none, type: InputChoice { options: ["printer-01", "printer-02"] } }, + DispatchInput { name: "printer_operation", description: Present { value: "Observe credential identity or start one pinned project" }, required: false, default: none, type: InputChoice { options: ["observe", "print"] } }, + DispatchInput { name: "printer_project_path", description: Present { value: "Absolute path of staged sliced project on srv1" }, required: false, default: none, type: InputString }, + DispatchInput { name: "printer_project_sha256", description: Present { value: "SHA-256 of the reviewed sliced project" }, required: false, default: none, type: InputString }, + DispatchInput { name: "printer_credential_version", description: Present { value: "Numeric Secret Manager version returned by observation" }, required: false, default: none, type: InputString }, + DispatchInput { name: "printer_bed_clear", description: Present { value: "Operator reports this printer bed is clear for this dispatch" }, required: false, default: none, type: InputChoice { options: ["false", "true"] } }, DispatchInput { name: "transaction_nonce", description: Present { value: "Operator-minted unique nonce for one RLM transaction; echoed into run-name so each dispatched run is API-selectable by its displayTitle, closing the run-id correlation gap (review 5062738052 B4). Not consumed by any step" }, @@ -4450,3 +4469,47 @@ fn fleet_converge_namecheap_observe_job() -> Job { } }, } } + +// The printer's identity pins this job to main and to its dedicated environment. +// Inputs are data passed through env, never interpolated into executable shell. +fn fleet_converge_printer_job() -> Job { + Job { + id: fleet_converge_mode_job_id(mode: PrinterWorkflow) as String, name: none, + runner: SelfHosted { labels: ["self-hosted", "linux", "arm64", approval_broker_host_identity() as String] }, + environment: Present { value: printer_environment as String }, + steps: [ + fleet_converge_event_sha_checkout_step(), ci_release_bins_download_step(), ci_release_bins_unpack_verify_step(), + ci_wif_auth_step_for(name: "WIF auth (dedicated printer credential reader)", + provider_resource: printer_provider_resource(), service_account: printer_service_account as String), + RunStep { + name: Present { value: "Printer: observe credential or deliver and start the pinned project" }, + id: Present { value: "printer" }, run: gunbc_ci_printer_workflow_invoke(), shell: none, + env: Present { value: [ + kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}")), + kv(key: "PRINTER_TARGET", value: yaml_string(s: "${{ inputs.printer }}")), + kv(key: "PRINTER_OPERATION", value: yaml_string(s: "${{ inputs.printer_operation }}")), + kv(key: "PRINTER_PROJECT_PATH", value: yaml_string(s: "${{ inputs.printer_project_path }}")), + kv(key: "PRINTER_PROJECT_SHA256", value: yaml_string(s: "${{ inputs.printer_project_sha256 }}")), + kv(key: "PRINTER_CREDENTIAL_VERSION", value: yaml_string(s: "${{ inputs.printer_credential_version }}")), + kv(key: "PRINTER_BED_CLEAR", value: yaml_string(s: "${{ inputs.printer_bed_clear }}")), + ] }, + working_directory: none, if_condition: none, continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_gunbc_run_resolve_step_timeout_minutes }, + }, + fleet_converge_receipt_upload_step(id: "printer_receipt", name: "Upload printer operation receipt", + artifact: "printer-operation", path: printer_workflow_receipt_path, if_condition: "success()"), + ], + needs: fleet_converge_job_needs(id: fleet_converge_mode_job_id(mode: PrinterWorkflow)), + env: none, outputs: none, + if_condition: Present { value: fleet_converge_mode_step_if(mode: PrinterWorkflow) }, + timeout_minutes: Present { value: gunbc_ci_gunbc_run_resolve_step_timeout_minutes + 20 }, + continue_on_error: none, + concurrency: Present { value: ConcurrencyMappingQueueNotMax { + group: "printer-${{ inputs.printer }}", cancel_in_progress: Present { value: CancelInProgressBool { value: false } }, explicit_single: none, + } }, + permissions: Present { value: WorkflowPermissions { + contents: Present { value: PermRead }, pull_requests: none, issues: none, + actions: Present { value: PermRead }, id_token: Present { value: PermWrite }, + } }, + } +} diff --git a/dag/gunbc/fleet/fleet_printer_access.dag b/dag/gunbc/fleet/fleet_printer_access.dag index b6e8e9d383a..5cf5a4068d9 100644 --- a/dag/gunbc/fleet/fleet_printer_access.dag +++ b/dag/gunbc/fleet/fleet_printer_access.dag @@ -1,5 +1,11 @@ module gunbc.fleet_printer_access +import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } +import gunbc.auth.secret_ref_credential { + fetch_secret_ref_credential, SecretCredentialReady, SecretCredentialFetchRefused, + SecretCredentialAccessUpsertRequired, SecretCredentialWireDecodeRefused, + SecretCredentialResolvedVersionMismatch, +} import std.types { Bool, Int, List, NonEmptyStr, Secret, String } import product.placement_supply { PhysicalAssetIdentity } import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending, secret_ref_secret_resource, gcp_resource_names_this_secret } @@ -487,3 +493,45 @@ fn materialization_lookup_for( } } } + + +// Routine printing reads an existing exact version. Provisioning evidence remains a +// separate route for onboarding; an ordinary print never acquires it by writing a secret. +// A requested version is intent only: the positive resolution requires a matching live read. +type PrinterCredentialAccess + = PrinterProvisioningEvidence { materializations: List } + | PrinterExistingVersion { version: NonEmptyStr } + +fn resolve_printer_credential_access( + printer: PhysicalAssetIdentity, + token_source: AccessTokenSource, + access: PrinterCredentialAccess, +) -> PrinterCredentialResolution { + match access { + PrinterProvisioningEvidence { materializations: ms } => + observe_printer_secret_container(printer: printer, token_source: token_source, materializations: ms) + PrinterExistingVersion { version: version } => + if !sm_version_id_is_number(version: version as String) { + PrinterCredentialObservationRefused { printer: printer, + cause: "Routine printing requires an observed numeric credential version; aliases and pending refuse" } + } else { + match printer_access_code_locus(printer: printer) { + PrinterAccessCodeLocusUnknown { printer: p } => PrinterCredentialLocusUndeclared { printer: p } + PrinterAccessCodeLocusConflict { printer: p, matches: n } => PrinterCredentialLocusConflicted { printer: p, matches: n } + PrinterAccessCodeDesiredLocusFound { locus: locus } => { + let exact = secret_ref_pin_version(ref: locus.secret, version: version) + match fetch_secret_ref_credential(secret_ref: exact, token_source: token_source) { + SecretCredentialReady { credential: c, resolved_version: _ } => + if (c as String) == "" { + PrinterCredentialObservationRefused { printer: printer, cause: "Printer credential is empty" } + } else { PrinterCredentialProvisioned { exact: exact } } + SecretCredentialFetchRefused { reason: r } => PrinterCredentialObservationRefused { printer: printer, cause: r } + SecretCredentialAccessUpsertRequired { plan: _ } => PrinterCredentialObservationRefused { printer: printer, cause: "Printer credential access requires the approval-gated IAM converge" } + SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => PrinterCredentialObservationRefused { printer: printer, cause: "Printer credential response did not decode" } + SecretCredentialResolvedVersionMismatch { requested: _, resolved: _ } => PrinterCredentialObservationRefused { printer: printer, cause: "Printer credential response named a different version" } + } + } + } + } + } +} diff --git a/dag/gunbc/fleet/printer_federation.dag b/dag/gunbc/fleet/printer_federation.dag new file mode 100644 index 00000000000..57186195b67 --- /dev/null +++ b/dag/gunbc/fleet/printer_federation.dag @@ -0,0 +1,35 @@ +module gunbc.fleet.printer_federation + +import std.types { String, NonEmptyStr, List, Map } +import v2.std.optional { Present } +import extdeps.cloud.gcp.gcp { ServiceAccountEmail, WifProvider, wif_provider_resource, wif_pool_resource } +import gunbc.auth.github_gcp_federation { fleet_secrets_project_number, github_wif_issuer_uri, federated_service_account_member } +import gunbc.auth.oidc_claim_pins { OidcClaimPin, claim_pins_attribute_condition, fleet_converge_job_claim_pins } + +data printer_environment: NonEmptyStr = "printer-lan" +data printer_wif_pool_id: NonEmptyStr = "github-printer-lan" +data printer_wif_provider_id: NonEmptyStr = "github-printer-lan-oidc" +data printer_service_account: ServiceAccountEmail = "printer-lan@gunbai-secrets.iam.gserviceaccount.com" +data printer_attribute_mapping: Map = { + "google.subject": "assertion.sub", + "attribute.repository_id": "assertion.repository_id", + "attribute.workflow_ref": "assertion.workflow_ref" +} + +fn printer_claim_pins() -> List { + fleet_converge_job_claim_pins(environment: printer_environment) +} +fn printer_attribute_condition() -> String { + claim_pins_attribute_condition(pins: printer_claim_pins()) +} +fn printer_principal_set() -> String { + join(["principalSet://iam.googleapis.com/", wif_pool_resource(project_number: fleet_secrets_project_number, pool_id: printer_wif_pool_id as String), "/*"], "") +} +fn printer_provider_resource() -> String { + wif_provider_resource(project_number: fleet_secrets_project_number, provider: WifProvider { + name: printer_wif_provider_id, pool: printer_wif_pool_id, + issuer_uri: github_wif_issuer_uri, + attribute_mapping: Present { value: printer_attribute_mapping }, + attribute_condition: Present { value: printer_attribute_condition() }, + }) +} diff --git a/dag/gunbc/fleet/printer_federation_provision.dag b/dag/gunbc/fleet/printer_federation_provision.dag new file mode 100644 index 00000000000..ddb81957634 --- /dev/null +++ b/dag/gunbc/fleet/printer_federation_provision.dag @@ -0,0 +1,30 @@ +module gunbc.fleet.printer_federation_provision + +import std.types { List } +import gunbc.auth.heal_publisher_provision { DedicatedFederation } +import gunbc.auth.gcp_secret_access { secret_accessor_grant } +import gunbc.auth.github_gcp_federation { federated_service_account_member } +import gunbc.fleet_printer_access { printer_access_code_loci } +import gunbc.fleet.printer_federation { + printer_wif_pool_id, printer_wif_provider_id, printer_attribute_mapping, + printer_attribute_condition, printer_principal_set, printer_service_account, +} + +// Desired IAM state for the shared ntfy-approved estate converge. No secret create, +// version write, deletion or policy-write permission is granted to the print worker. +fn printer_dedicated_federation() -> DedicatedFederation { + DedicatedFederation { + label: "Printer LAN credential reader", + pool_id: printer_wif_pool_id, + pool_display_name: "Printer LAN", + provider_id: printer_wif_provider_id, + attribute_mapping: printer_attribute_mapping, + attribute_condition: printer_attribute_condition(), + principal_set: printer_principal_set(), + service_account: printer_service_account, + service_account_id: "printer-lan", + service_account_display_name: "Printer LAN credential reader", + service_account_member: federated_service_account_member(email: printer_service_account), + secret_grants: map(printer_access_code_loci, locus => secret_accessor_grant(target: locus.secret)), + } +} diff --git a/dag/gunbc/fleet/printer_host_tools.dag b/dag/gunbc/fleet/printer_host_tools.dag new file mode 100644 index 00000000000..53339a2a3f8 --- /dev/null +++ b/dag/gunbc/fleet/printer_host_tools.dag @@ -0,0 +1,23 @@ +module gunbc.fleet.printer_host_tools + +import gunbc.fleet.printer_tool_readiness { printer_mqtt_tool_ready } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.tools.mosquitto_pub { mosquitto_pub_cli_tool } +import extdeps.tools { apt_package_of } +import gunbc.host_effect { LocalShell } +import gunbc.host_effect_realize { srv3_ensure_apt_tool, Srv3PredicateHolds, Srv3PredicateFails, Srv3PredicateUnobserved } + +// Administrator host preparation, separate from the unprivileged print worker. +// The package and version constraint come from the same tool row used at print admission. +fn converge() -> ProcessExit { + match apt_package_of(tool: mosquitto_pub_cli_tool) { + Absent => exit_failure(reason: "Printer MQTT tool has no modeled package source") + Present { value: package } => + match srv3_ensure_apt_tool(tool: mosquitto_pub_cli_tool, package: package, + require_version_probe: false, transport: LocalShell) { + Srv3PredicateHolds => if printer_mqtt_tool_ready() { ExitSuccess } else { exit_failure(reason: "Installed MQTT client did not establish version >= 2 through its help probe") } + Srv3PredicateFails => exit_failure(reason: "Printer MQTT tool did not read back after convergence") + Srv3PredicateUnobserved { reason: r } => exit_failure(reason: r) + } + } +} diff --git a/dag/gunbc/fleet/printer_job_start.dag b/dag/gunbc/fleet/printer_job_start.dag index 15ffd81269f..ec77cf8f803 100644 --- a/dag/gunbc/fleet/printer_job_start.dag +++ b/dag/gunbc/fleet/printer_job_start.dag @@ -1,5 +1,6 @@ module gunbc.fleet.printer_job_start +import gunbc.fleet_printer_access { PrinterCredentialAccess, PrinterProvisioningEvidence, resolve_printer_credential_access } import std.types { NonEmptyStr, String, Bool, Secret, FilePath, List } import extdeps.shell import extdeps.exec.command { ArgvCommand, CommandTransport, LocalExec, env_prefixed_command } @@ -334,6 +335,17 @@ fn start_print_on_printer( token_source: AccessTokenSource, materializations: List, ) -> PrintStart +{ + start_print_on_printer_with_access(printer: printer, remote_name: remote_name, token_source: token_source, + access: PrinterProvisioningEvidence { materializations: materializations }) +} + +fn start_print_on_printer_with_access( + printer: PhysicalAssetIdentity, + remote_name: NonEmptyStr, + token_source: AccessTokenSource, + access: PrinterCredentialAccess, +) -> PrintStart { match printer_serial(printer: printer) { PrinterSerialUnknown { printer: p } => @@ -357,8 +369,8 @@ fn start_print_on_printer( PrintStartRefused { printer: p, cause: "an endpoint names this printer but is not a LAN fabrication appliance, so it is not the control channel and the start refuses rather than authenticating to it" } PrinterAddressFound { address: lan_address } => - match observe_printer_secret_container( - printer: printer, token_source: token_source, materializations: materializations + match resolve_printer_credential_access( + printer: printer, token_source: token_source, access: access ) { PrinterCredentialObservationRefused { printer: p, cause: cause } => PrintStartRefused { printer: p, cause: cause } diff --git a/dag/gunbc/fleet/printer_project_delivery.dag b/dag/gunbc/fleet/printer_project_delivery.dag index d116f004693..b639859d61f 100644 --- a/dag/gunbc/fleet/printer_project_delivery.dag +++ b/dag/gunbc/fleet/printer_project_delivery.dag @@ -1,5 +1,6 @@ module gunbc.fleet.printer_project_delivery +import gunbc.fleet_printer_access { PrinterCredentialAccess, PrinterProvisioningEvidence, resolve_printer_credential_access } import std.types { NonEmptyStr, String, Bool, Secret, FilePath } import std.nat { Nat } import extdeps.cloud.gcp.secret_ref { SecretRef, secret_ref_version_resource } @@ -207,6 +208,8 @@ fn remote_name_for_digest(content: Digest) -> NonEmptyStr { concat(concat("gunbc-", content.hex as String), ".3mf") as NonEmptyStr } +fn project_payload_digest(payload: IdentifiedProjectPayload) -> Digest { payload.content } + fn project_remote_name(payload: IdentifiedProjectPayload) -> NonEmptyStr { remote_name_for_digest(content: payload.content) } @@ -642,6 +645,17 @@ fn deliver_project_to_printer( token_source: AccessTokenSource, materializations: List, ) -> ProjectDelivery +{ + deliver_project_to_printer_with_access(printer: printer, payload: payload, token_source: token_source, + access: PrinterProvisioningEvidence { materializations: materializations }) +} + +fn deliver_project_to_printer_with_access( + printer: PhysicalAssetIdentity, + payload: IdentifiedProjectPayload, + token_source: AccessTokenSource, + access: PrinterCredentialAccess, +) -> ProjectDelivery { match printer_endpoint_address(printer: printer) { PrinterAddressUnknown { printer: p } => @@ -654,7 +668,7 @@ fn deliver_project_to_printer( refuse_and_drop(printer: p, payload: payload, cause: "an endpoint names this printer but is not a LAN fabrication appliance, so it is not the file channel and delivery refuses rather than authenticating to it") PrinterAddressFound { address: lan_address } => - match observe_printer_secret_container(printer: printer, token_source: token_source, materializations: materializations) { + match resolve_printer_credential_access(printer: printer, token_source: token_source, access: access) { PrinterCredentialObservationRefused { printer: p, cause: cause } => refuse_and_drop(printer: p, payload: payload, cause: cause) PrinterCredentialLocusUndeclared { printer: p } => diff --git a/dag/gunbc/fleet/printer_tool_readiness.dag b/dag/gunbc/fleet/printer_tool_readiness.dag new file mode 100644 index 00000000000..c652a5d7390 --- /dev/null +++ b/dag/gunbc/fleet/printer_tool_readiness.dag @@ -0,0 +1,19 @@ +module gunbc.fleet.printer_tool_readiness + +import std.types { Bool } +import extdeps.tools.mosquitto_pub { mosquitto_pub_cli_tool, mosquitto_pub_help_command, mosquitto_pub_help_supports_private_options } +import gunbc.host_cli_dependency { observe_host_cli_dependency, HostCliDependencyPresent, HostCliDependencyAbsent } +import gunbc.command_runner { run_shell_command_capture } +import extdeps.exec.command { LocalExec } +import extdeps.shell.exec { ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } + +fn printer_mqtt_tool_ready() -> Bool { + match observe_host_cli_dependency(tool: mosquitto_pub_cli_tool) { + HostCliDependencyAbsent { absence: _ } => false + HostCliDependencyPresent => match run_shell_command_capture(command: mosquitto_pub_help_command(), transport: LocalExec) { + ProcessOutputPresent { text: t } => mosquitto_pub_help_supports_private_options(stdout: t) + ProcessOutputAbsent => false + ProcessRefused { exit_code: _, stderr: _ } => false + } + } +} diff --git a/dag/gunbc/fleet/printer_workflow_artifact.dag b/dag/gunbc/fleet/printer_workflow_artifact.dag new file mode 100644 index 00000000000..ac2d95fce44 --- /dev/null +++ b/dag/gunbc/fleet/printer_workflow_artifact.dag @@ -0,0 +1,6 @@ +module gunbc.fleet.printer_workflow_artifact + +import std.types { String } + +// The runtime writer, CI display and workflow uploader share one receipt path. +data printer_workflow_receipt_path: String = "printer-workflow-receipt.json" diff --git a/dag/gunbc/fleet/printer_workflow_run.dag b/dag/gunbc/fleet/printer_workflow_run.dag new file mode 100644 index 00000000000..b6cf65c2c7b --- /dev/null +++ b/dag/gunbc/fleet/printer_workflow_run.dag @@ -0,0 +1,150 @@ +module gunbc.fleet.printer_workflow_run + +import gunbc.fleet.printer_tool_readiness { printer_mqtt_tool_ready } +import gunbc.fleet.printer_workflow_artifact { printer_workflow_receipt_path } +import std.types { String, NonEmptyStr, Bool } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.content_hash { sha256_hex_digest } +import std.decimal { decimal_digits_only } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } +import gunbc.secret_provision { secret_ref_pin_version } +import gunbc.fleet_asset_identity { PhysicalAssetIdentity, printer_01, printer_02 } +import gunbc.fleet_printer_access { + printer_access_code_locus, PrinterAccessCodeDesiredLocusFound, + PrinterAccessCodeLocusUnknown, PrinterAccessCodeLocusConflict, PrinterExistingVersion, +} +import gunbc.auth.access_token_source { WorkloadIdentityToken } +import gunbc.auth.secret_ref_credential { + fetch_secret_ref_credential, SecretCredentialReady, SecretCredentialAccessUpsertRequired, + SecretCredentialWireDecodeRefused, SecretCredentialFetchRefused, SecretCredentialResolvedVersionMismatch, +} +import gunbc.actions_run_binding { actions_variable_trimmed } +import extdeps.languages.json.emit { json_object, json_kv, json_string, serialize_json } +import gunbc.fleet.printer_project_delivery { + PrinterProjectPayload, identify_project_payload, PayloadIdentified, PayloadIdentificationRefused, + PayloadIdentificationRefusedResidueRemains, project_payload_digest, abandon_payload, + deliver_project_to_printer_with_access, ProjectDelivered, ProjectDeliveryRefused, + ProjectDeliveryUnattempted, ProjectTransferredCleanupRefused, ProjectDeliveryProvisionRequired, + ProjectDeliveryRefusedResidueRemains, ProjectDeliveryUnattemptedResidueRemains, + ProjectDeliveryProvisionRequiredResidueRemains, receipt_remote_name, +} +import gunbc.fleet.printer_job_start { + start_print_on_printer_with_access, PrintStartPublished, PrintStartRefused, + PrintStartOutcomeUnknown, PrintStartOutcomeUnknownResidueRemains, + PrintStartRefusedResidueRemains, PrintStartPublishedCleanupRefused, +} + + + +type PrinterWorkflowSelection = PrinterSelected { printer: PhysicalAssetIdentity } | PrinterSelectionRefused +fn select_printer(name: String) -> PrinterWorkflowSelection { + if name == (printer_01 as String) { PrinterSelected { printer: printer_01 } } + else if name == (printer_02 as String) { PrinterSelected { printer: printer_02 } } + else { PrinterSelectionRefused } +} + +// Validate before any credential access, payload copy or network effect. A GitHub rerun +// is not authority to repeat a physical start; use observation to reconcile it first. +fn print_inputs_admitted(version: String, digest: String, bed_clear: String, run_attempt: String) -> Bool { + sm_version_id_is_number(version: version) && bed_clear == "true" && run_attempt == "1" && + match sha256_hex_digest(hex: digest) { Present { value: _ } => true Absent => false } +} + +fn write_receipt(printer: PhysicalAssetIdentity, standing: String, version: String, remote: String, digest: String) -> ProcessExit { + let content = serialize_json(v: json_object(members: [ + json_kv(key: "printer", value: json_string(s: printer as String)), + json_kv(key: "standing", value: json_string(s: standing)), + json_kv(key: "credential_version", value: json_string(s: version)), + json_kv(key: "remote_name", value: json_string(s: remote)), + json_kv(key: "project_sha256", value: json_string(s: digest)), + json_kv(key: "run_id", value: json_string(s: actions_variable_trimmed(name: "GITHUB_RUN_ID"))), + json_kv(key: "run_attempt", value: json_string(s: actions_variable_trimmed(name: "GITHUB_RUN_ATTEMPT"))), + json_kv(key: "revision", value: json_string(s: actions_variable_trimmed(name: "GITHUB_SHA"))), + ])) + let written = Filesystem.Write(path: printer_workflow_receipt_path, content: content) + if written.success { ExitSuccess } else { exit_failure(reason: "Printer operation completed but receipt write failed; do not replay a start") } +} + +// Discovery reads latest once and records the provider's numeric identity, never the code. +// Starting is a different operation and requires that exact numeric pin. +fn observe_credential(printer: PhysicalAssetIdentity) -> ProcessExit { + match printer_access_code_locus(printer: printer) { + PrinterAccessCodeLocusUnknown { printer: _ } => exit_failure(reason: "Printer credential locus is absent") + PrinterAccessCodeLocusConflict { printer: _, matches: _ } => exit_failure(reason: "Printer credential locus is ambiguous") + PrinterAccessCodeDesiredLocusFound { locus: locus } => + match fetch_secret_ref_credential(secret_ref: secret_ref_pin_version(ref: locus.secret, version: "latest"), token_source: WorkloadIdentityToken) { + SecretCredentialReady { credential: c, resolved_version: version } => + if (c as String) == "" { exit_failure(reason: "Printer credential is empty") } + else { write_receipt(printer: printer, standing: "credential-observed; printer not contacted", version: version as String, remote: "", digest: "") } + SecretCredentialAccessUpsertRequired { plan: _ } => exit_failure(reason: "Printer federation needs the ntfy-approved IAM converge") + SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => exit_failure(reason: "Printer credential response did not decode") + SecretCredentialFetchRefused { reason: r } => exit_failure(reason: r as String) + SecretCredentialResolvedVersionMismatch { requested: _, resolved: _ } => exit_failure(reason: "Printer credential response names another resource") + } + } +} + +fn print_project(printer: PhysicalAssetIdentity, path: String, digest: String, version: NonEmptyStr) -> ProcessExit { + if !printer_mqtt_tool_ready() { + exit_failure(reason: "Printer MQTT tool missing or below version 2; run gunbc.fleet.printer_host_tools converge as the host administrator") + } else { print_project_with_tools(printer: printer, path: path, digest: digest, version: version) } +} + +fn print_project_with_tools(printer: PhysicalAssetIdentity, path: String, digest: String, version: NonEmptyStr) -> ProcessExit { + match identify_project_payload(payload: PrinterProjectPayload { local_path: path as NonEmptyStr }) { + PayloadIdentificationRefused { local_path: _, cause: c } => exit_failure(reason: c as String) + PayloadIdentificationRefusedResidueRemains { local_path: _, residue_path: residue, cause: c } => exit_failure(reason: join(["Payload residue remains at ", residue as String, ": ", c as String], "")) + PayloadIdentified { payload: payload } => + if (project_payload_digest(payload: payload).hex as String) != digest { + let cleaned = abandon_payload(payload: payload) + exit_failure(reason: if cleaned { "Project digest differs from the requested print; nothing uploaded" } else { "Project digest differs; private payload cleanup also failed" }) + } else { + match deliver_project_to_printer_with_access(printer: printer, payload: payload, token_source: WorkloadIdentityToken, access: PrinterExistingVersion { version: version }) { + ProjectDelivered { receipt: receipt } => { + let remote = receipt_remote_name(r: receipt) + match start_print_on_printer_with_access(printer: printer, remote_name: remote, token_source: WorkloadIdentityToken, access: PrinterExistingVersion { version: version }) { + PrintStartPublished { receipt: _ } => write_receipt(printer: printer, standing: "start-published; RUNNING not yet observed", version: version as String, remote: remote as String, digest: digest) + PrintStartRefused { printer: _, cause: c } => exit_failure(reason: c as String) + PrintStartRefusedResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Start refused; credential residue remains: ", c as String)) + PrintStartOutcomeUnknown { printer: _, cause: c } => exit_failure(reason: concat("Start outcome unknown; DO NOT REPLAY: ", c as String)) + PrintStartOutcomeUnknownResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Start outcome unknown with residue; DO NOT REPLAY: ", c as String)) + PrintStartPublishedCleanupRefused { receipt: _, cause: c } => exit_failure(reason: concat("Start published but cleanup refused; DO NOT REPLAY: ", c as String)) + } + } + ProjectDeliveryRefused { printer: _, cause: c } => exit_failure(reason: c as String) + ProjectDeliveryUnattempted { printer: _, cause: c } => exit_failure(reason: c as String) + ProjectDeliveryProvisionRequired { printer: _, desired: _ } => exit_failure(reason: "Credential absent; routine printing does not provision it") + ProjectTransferredCleanupRefused { receipt: _, cause: c } => exit_failure(reason: concat("Transferred but cleanup failed; start withheld: ", c as String)) + ProjectDeliveryRefusedResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Delivery refused with residue: ", c as String)) + ProjectDeliveryUnattemptedResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Delivery unattempted with residue: ", c as String)) + ProjectDeliveryProvisionRequiredResidueRemains { printer: _, desired: _, cause: c } => exit_failure(reason: concat("Credential absent with residue: ", c as String)) + } + } + } +} + +fn run() -> ProcessExit { + let run_id = actions_variable_trimmed(name: "GITHUB_RUN_ID") + let attempt = actions_variable_trimmed(name: "GITHUB_RUN_ATTEMPT") + let operation = actions_variable_trimmed(name: "PRINTER_OPERATION") + if run_id == "" || !decimal_digits_only(s: run_id) { + exit_failure(reason: "Printer workflow requires an attributed Actions run") + } else { + match select_printer(name: actions_variable_trimmed(name: "PRINTER_TARGET")) { + PrinterSelectionRefused => exit_failure(reason: "Printer identity is not in the fleet roster") + PrinterSelected { printer: printer } => + if operation == "observe" { observe_credential(printer: printer) } + else if operation != "print" { exit_failure(reason: "Unknown printer operation") } + else { + let path = actions_variable_trimmed(name: "PRINTER_PROJECT_PATH") + let digest = actions_variable_trimmed(name: "PRINTER_PROJECT_SHA256") + let version = actions_variable_trimmed(name: "PRINTER_CREDENTIAL_VERSION") + if path == "" || !print_inputs_admitted(version: version, digest: digest, + bed_clear: actions_variable_trimmed(name: "PRINTER_BED_CLEAR"), run_attempt: attempt) { + exit_failure(reason: "Print requires a project path, SHA-256, observed numeric credential version, clear-bed report, and first run attempt") + } else { print_project(printer: printer, path: path, digest: digest, version: version as NonEmptyStr) } + } + } + } +} diff --git a/dag/test/claim/printer_workflow_binding_witness_test.dag b/dag/test/claim/printer_workflow_binding_witness_test.dag new file mode 100644 index 00000000000..9d77042279b --- /dev/null +++ b/dag/test/claim/printer_workflow_binding_witness_test.dag @@ -0,0 +1,78 @@ +module test.claim.printer_workflow_binding_witness_test + +import std.types { Bool, String } +import v2.std.optional { Present, Absent } +import extdeps.github.actions { Step, RunStep, UsesStep } +import extdeps.languages.yaml.types { YamlString } +import gunbc.fleet_converge_workflow { + PrinterWorkflow, FleetSshKeyNotConsumed, + fleet_converge_mode_options, fleet_converge_mode_fleet_ssh_key_demand, + fleet_converge_printer_job, fleet_converge_shared_job_if, + fleet_converge_checkout_ref_expr, fleet_converge_event_sha_checkout_ref_expr, +} +import gunbc.ci_spec { gunbc_run_step_targets } +import gunbc.fleet.printer_federation { printer_service_account, printer_provider_resource } + +import test.claim.printer_workflow_witness_test { + w_print_needs_exact_version_clear_bed_digest_and_first_attempt, + w_unknown_printer_never_selects_a_default_machine, + w_printer_federation_rejects_other_workflow_branch_and_environment, + w_tool_probe_requires_mosquitto_version_two_or_newer, +} +import test.claim.printer_job_start_witness_test { no_start_outcome_authorizes_a_second_publish } +import test.claim.gcp_iam_converge_witness_test { + every_target_is_bound_resource_locally_and_the_own_trust_is_not, + only_a_live_approval_admits_the_mint_gate_or_the_apply, +} +import gunbc.fleet.printer_federation_provision { printer_dedicated_federation } +import gunbc.fleet.printer_host_tools { converge } + +fn auth_step_matches(st: Step) -> Bool { + match st { + UsesStep { name: _, id, uses: _, with: args, env: _, if_condition: _, continue_on_error: _, timeout_minutes: _ } => + match id { Present { value: value } => value == "wif_auth" && match args { + Present { value: fields } => any(fields, f => f.key == "service_account" && match f.value { YamlString { value: v } => v == (printer_service_account as String) _ => false }) + && any(fields, f => f.key == "workload_identity_provider" && match f.value { YamlString { value: v } => v == printer_provider_resource() _ => false }) + Absent => false + } Absent => false } + _ => false + } +} +test fn separate_job_uses_dedicated_identity() -> Bool { + let job = fleet_converge_printer_job() + job.id == "printer" && match job.environment { Present { value } => value == "printer-lan" Absent => false } + && count(filter(job.steps, s => auth_step_matches(st: s))) == 1 + && any(job.needs, n => n == "build") +} +test fn observer_has_no_fleet_ssh_demand() -> Bool { + match fleet_converge_mode_fleet_ssh_key_demand(mode: PrinterWorkflow) { FleetSshKeyNotConsumed => true _ => false } +} +test fn trusted_code_cannot_be_replaced_by_dispatch_revision() -> Bool { + string_contains(s: fleet_converge_checkout_ref_expr, pattern: "github.event.inputs.mode == 'printer'") + && fleet_converge_event_sha_checkout_ref_expr == "${{ github.sha }}" + && string_contains(s: fleet_converge_shared_job_if, pattern: "github.event.inputs.mode != 'printer'") +} +test fn observer_is_a_registered_dispatch_and_run_target() -> Bool { + any(fleet_converge_mode_options, m => m == "printer") + && count(filter(gunbc_run_step_targets(), t => t.entry == "dag/gunbc/fleet/printer_workflow_run.dag" && t.function == "run")) == 1 +} + +test fn print_input_and_tool_controls_hold() -> Bool { + w_print_needs_exact_version_clear_bed_digest_and_first_attempt() && + w_unknown_printer_never_selects_a_default_machine() && + w_tool_probe_requires_mosquitto_version_two_or_newer() +} + +test fn approval_and_start_replay_regressions_hold() -> Bool { + w_printer_federation_rejects_other_workflow_branch_and_environment() && + no_start_outcome_authorizes_a_second_publish() && + every_target_is_bound_resource_locally_and_the_own_trust_is_not() && + only_a_live_approval_admits_the_mint_gate_or_the_apply() +} + +test fn printer_identity_reads_only_the_two_printer_secrets() -> Bool { + let f = printer_dedicated_federation() + length(f.secret_grants) == 2 && + all(f.secret_grants, g => g.role == "roles/secretmanager.secretAccessor" && + ((g.target.secret as String) == "printer-01-lan-access-code" || (g.target.secret as String) == "printer-02-lan-access-code")) +} diff --git a/dag/test/claim/printer_workflow_witness_test.dag b/dag/test/claim/printer_workflow_witness_test.dag new file mode 100644 index 00000000000..44af5e10457 --- /dev/null +++ b/dag/test/claim/printer_workflow_witness_test.dag @@ -0,0 +1,37 @@ +module test.claim.printer_workflow_witness_test + +import std.types { Bool, String } +import extdeps.tools.mosquitto_pub { mosquitto_pub_help_supports_private_options } +import gunbc.fleet.printer_workflow_run { print_inputs_admitted, select_printer, PrinterSelected, PrinterSelectionRefused } +import gunbc.fleet.printer_federation { printer_claim_pins } +import gunbc.auth.oidc_claim_pins { claim_pins_admit, OidcClaimPin } + +data project_digest: String = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +test fn w_print_needs_exact_version_clear_bed_digest_and_first_attempt() -> Bool { + print_inputs_admitted(version: "1", digest: project_digest, bed_clear: "true", run_attempt: "1") && + !print_inputs_admitted(version: "latest", digest: project_digest, bed_clear: "true", run_attempt: "1") && + !print_inputs_admitted(version: "pending", digest: project_digest, bed_clear: "true", run_attempt: "1") && + !print_inputs_admitted(version: "1", digest: "", bed_clear: "true", run_attempt: "1") && + !print_inputs_admitted(version: "1", digest: project_digest, bed_clear: "false", run_attempt: "1") && + !print_inputs_admitted(version: "1", digest: project_digest, bed_clear: "true", run_attempt: "2") +} + +test fn w_unknown_printer_never_selects_a_default_machine() -> Bool { + match select_printer(name: "printer-03") { PrinterSelectionRefused => true PrinterSelected { printer: _ } => false } +} + +test fn w_printer_federation_rejects_other_workflow_branch_and_environment() -> Bool { + let pins = printer_claim_pins() + claim_pins_admit(pins: pins, claims: pins) && + !claim_pins_admit(pins: pins, claims: map(pins, p => if p.claim == "ref" { OidcClaimPin { claim: p.claim, value: "refs/heads/untrusted" } } else { p })) && + !claim_pins_admit(pins: pins, claims: map(pins, p => if p.claim == "environment" { OidcClaimPin { claim: p.claim, value: "namecheap-dns" } } else { p })) && + !claim_pins_admit(pins: pins, claims: filter(pins, p => p.claim != "workflow_ref")) +} + +test fn w_tool_probe_requires_mosquitto_version_two_or_newer() -> Bool { + mosquitto_pub_help_supports_private_options(stdout: "mosquitto_pub version 2.0.18 running on libmosquitto 2.0.18.") && + !mosquitto_pub_help_supports_private_options(stdout: "mosquitto_pub version 1.6.12 running on libmosquitto 1.6.12.") && + !mosquitto_pub_help_supports_private_options(stdout: "") && + !mosquitto_pub_help_supports_private_options(stdout: "unrelated tool 2.0") +} diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 31246d5a011..16201f3cb5f 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -15,7 +15,7 @@ This dated status supersedes the pre-arrival implementation narrative below. solid-readback acceptance is not established by that fact and remains to be audited. - **Physical printing reached.** PRINT-N records printer-02 reporting `RUNNING` on the gauge project after FTPS delivery and MQTT start through the modeled path. The operator now confirms - the gauge is in hand. No printer has been contacted in this session. + the gauge is in hand. No authenticated printer operation has been performed in this session. - **Coupon feedback recorded.** `hole_fit_measurement.operator_ladder_reading_2026_09_04` records M3 interference / tight / free at 3.00 / 3.10 / 3.20 mm nominal rungs. Printer attribution remains absent; this is not universal hole compensation or qualification of both machines. @@ -127,8 +127,47 @@ hashes are staged under `/home/briansrls/print-prep-2026-10-03/two-printer-batch printer. Material/colour reports are now modeled: Creality PLA, white on printer-01 and black on printer-02. Physical spool identity and current bed-clear state are not established by those reports. Nozzle/plate reconciliation and slicing are pending. -The routine authenticated print entry point is also outstanding, as recorded above. No printer -start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. +The routine workload-identity entry point is implemented in the migration below; deployment and +final slicing remain pending. No printer start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. + +### Printer authentication migration to the ntfy workflow + +The operator requested migration on 2026-10-03. `gunbc.fleet.printer_federation_provision` +adds a dedicated `printer-lan` reader to the existing ntfy-approved GCP IAM estate. It requests +access only to the two rostered printer secrets; the print worker cannot create, rotate or delete +credentials or change IAM policy. The provider pins the repository, main workflow/ref, dispatch +and `printer-lan` environment through the shared OIDC claim authority. + +`fleet-converge` mode `printer` has two operations: + +1. `observe` resolves a printer's existing credential once and writes its exact version identity + to a receipt. The receipt contains no access code. It performs no printer action. +2. `print` requires the selected printer, staged project path on srv1, expected SHA-256, numeric + credential version and an explicit clear-bed report. It hashes a private copy before upload, + uses the same version for FTPS and MQTT, and refuses a workflow rerun. A successful receipt + means **start published**, not a claim that the machine reported RUNNING. Unknown outcomes + require observation, never automatic replay. + +The dedicated job runs on srv1, checks out the dispatch event SHA even if `expected_revision` is +supplied, and serializes starts per printer without cancelling an in-flight operation. It uses +`WorkloadIdentityToken`; no operator-token fallback and no destructive credential migration. +`gunbc.fleet.printer_host_tools.converge` is administrator preparation for the MQTT tool, using +the existing apt ensure and the tool's declared package. The worker checks the actual client +version before any upload. Mosquitto identifies its version through the documented `--help` +interface ([upstream manual](https://mosquitto.org/man/mosquitto_pub-1.html)). + +**Deployment standing:** source changes are not evidence of installed IAM grants or a dispatched +print. The main-pinned workflow must land before it can federate. The existing IAM bootstrap must +cover the new target resources so its observer can plan and its approved executor can apply those +resource-local bindings. Then run the shared `gcp_iam_converge` workflow and approve its concrete +diff through ntfy, prepare the srv1 MQTT tool, and run credential observation. Final sliced projects +and live printer-state verification remain required before dispatching these gauges. The operator +reported both beds clear during this session; do not carry that report to an unrelated later print. + +Validation: all seven printer workflow binding, admission, tool-version, OIDC, IAM approval and +start-replay regression tests passed. The canonical artifact gate regenerated the workflow; YAML +structure assertions and shell syntax checks passed. These are source checks, not live deployment +or printer observations. ### Local preparation environment From a82c5f340c5845e36acb48511bd10d8122950394 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 21:40:54 +0000 Subject: [PATCH 03/24] Enable verified gauge slicing and ntfy-approved operator printing --- dag/extdeps/exec/command.dag | 1 + dag/extdeps/exec/program.dag | 1 + dag/extdeps/printing/orca_slicer.dag | 86 ++++++------ dag/extdeps/tools/mosquitto_pub.dag | 17 ++- dag/extdeps/tools/mosquitto_sub.dag | 34 +++++ dag/gunbc/auth/mosquitto_options_binding.dag | 16 +++ dag/gunbc/fleet/printer_operator_approval.dag | 123 ++++++++++++++++++ dag/gunbc/fleet/printer_operator_run.dag | 24 ++++ dag/gunbc/fleet/printer_project_delivery.dag | 6 +- dag/gunbc/fleet/printer_report.dag | 84 ++++++++++++ dag/gunbc/fleet/printer_tool_readiness.dag | 13 +- dag/gunbc/fleet/printer_workflow_run.dag | 24 +++- .../product/printed_chassis/slice_run.dag | 56 ++++++++ .../printed_chassis/slicing_toolchain.dag | 5 +- ...printer_operator_approval_witness_test.dag | 42 ++++++ .../claim/printer_workflow_witness_test.dag | 2 +- docs/plans/printed-chassis-program.md | 41 ++++-- 17 files changed, 502 insertions(+), 73 deletions(-) create mode 100644 dag/extdeps/tools/mosquitto_sub.dag create mode 100644 dag/gunbc/fleet/printer_operator_approval.dag create mode 100644 dag/gunbc/fleet/printer_operator_run.dag create mode 100644 dag/gunbc/fleet/printer_report.dag create mode 100644 dag/gunbc/product/printed_chassis/slice_run.dag create mode 100644 dag/test/claim/printer_operator_approval_witness_test.dag diff --git a/dag/extdeps/exec/command.dag b/dag/extdeps/exec/command.dag index 3916c630903..9b2be81a6a9 100644 --- a/dag/extdeps/exec/command.dag +++ b/dag/extdeps/exec/command.dag @@ -174,6 +174,7 @@ fn argv_command(program: ProgramIdentity, arguments: List) -> ArgvComman decl_ref(module_path: "extdeps.tools.websocat", decl_name: "websocat_nbd_proxy_bridge_command"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_publish_command"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_pub_help_command"), + decl_ref(module_path: "extdeps.tools.mosquitto_sub", decl_name: "mosquitto_report_command"), decl_ref(module_path: "extdeps.tools.gnu_time", decl_name: "gnu_time_max_rss_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_version_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_user_manager_probe_command"), diff --git a/dag/extdeps/exec/program.dag b/dag/extdeps/exec/program.dag index b7f503836de..f96e741054d 100644 --- a/dag/extdeps/exec/program.dag +++ b/dag/extdeps/exec/program.dag @@ -62,6 +62,7 @@ fn cataloged_program(invocation: NonEmptyStr) -> ProgramIdentity decl_ref(module_path: "extdeps.tools.sed", decl_name: "sed_program"), decl_ref(module_path: "extdeps.tools.conntrack", decl_name: "conntrack_program"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_pub_program"), + decl_ref(module_path: "extdeps.tools.mosquitto_sub", decl_name: "mosquitto_sub_program"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_httpd_command"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_tftpd_over_udpsvd_command"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_dd_sparse_image_command"), diff --git a/dag/extdeps/printing/orca_slicer.dag b/dag/extdeps/printing/orca_slicer.dag index e4ae0bef505..7a2fccbe32e 100644 --- a/dag/extdeps/printing/orca_slicer.dag +++ b/dag/extdeps/printing/orca_slicer.dag @@ -2,7 +2,13 @@ module extdeps.printing.orca_slicer import extdeps.exec.program { uncataloged_program } -import std.types { String, NonEmptyStr, List } +import std.types { String, NonEmptyStr, List, FilePath } +import extdeps.shell +import extdeps.shell.exec { ProcessOutcome, ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } +import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } +import extdeps.tools.chmod { chmod_set_mode_command, chmod_path_resolved_program } +import gunbc.command_runner { run_shell_command_capture } +import extdeps.exec.command { LocalExec } import std.nat { Nat } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } @@ -80,49 +86,12 @@ data orca_slicer_linux_aarch64_appimage_sha256: NonEmptyStr = "e1a07275a25f17662 // clearing the floor as "runs" is overstating its own measurement. data orca_slicer_required_glibc: GlibcVersion = glibc_version(major: 2, minor: 38) -// THE EXECUTABLE IS ITS OWN SUBJECT, SEALED, AND THIS MODULE IS THE ONLY PLACE THAT CAN SAY WHAT -// COUNTS AS ORCA. A consumer that took a bare ArgvCommand could run `true`, observe a genuine zero -// exit, and carry the result forward as evidence that a slicer executes -- generic executability -// read as ORCA executability. The subject was not substituted there and the receipt was not faked; -// the executed thing was simply of the wrong kind, which no amount of care at the consumer can see. -// -// The kind is therefore constructional rather than checked: OrcaExecutable has one constructor, so a -// caller cannot spell an Orca invocation over some other program. What that constructor CONSUMES is -// settled below, and today the answer is that nothing does. That is DESIGN section 3 as much as -// section 5: what counts as this upstream product is a fact this module owns, and a consumer -// comparing a program string to "orca-slicer" would be a second authority for it -- and a string -// check besides, which the AppImage's own filename already shows is unreliable. -// -// AND THERE IS NO MINT, WHICH IS THE HONEST STATE RATHER THAN A GAP. -// -// The kind question was real: `true --version` exits zero on every host, so a consumer taking a bare -// ArgvCommand could read generic executability as ORCA executability. Sealing the executable answers -// it, and the mint must consume evidence rather than a string -- an unrestricted -// `orca_executable(program: NonEmptyStr)` would hand back the very authority the seal holds, and -// restricting its CALLERS only moves that one function outward. -// -// A DIGEST CHECK AT MINT TIME IS NOT ENOUGH, and this is where the previous revision stopped. It -// verified the bytes at a path and then stored THE PATH. A path is a mutable locator, not the -// content identity that earned the seal: verify the real AppImage, receive the seal, replace the -// file, and the capability is spent against whatever now occupies that path. True when minted, false -// when used. That matters most in this module, which insists the 2.38 floor belongs to THIS -// content -- so a seal that decays to a name reintroduces exactly the inheritance it refuses. -// -// gunbc.package_delivery already refuses to treat a staging path as durable receipt identity, and -// the Spark runtime publishes into a root derived from materialization identity rather than trusting -// an arbitrary mutable path. Either shape would close this: re-verify at every positive spend, or -// materialize into an owned content-addressed realization the probe and the slice both consume. -// -// NEITHER BELONGS IN THIS PR, so the positive capability stays UNINHABITED. There is no way to -// obtain an OrcaExecutable, and therefore no way to reach ExecutionReady or a SlicePlan. The direct- -// host route keeps what it actually established -- the measured ABI floor, and two recorded hosts -// that cleared it and still refused -- which is useful and honest on its own. A coproduct having a -// Ready arm is not a reason to manufacture a way to reach it; that is the same fabrication as the -// three slicing witnesses this PR deleted and the export positive control it just deleted. -// -// NEXT-RUNG TRIGGER: a content-addressed materialization of the selected asset, from which the -// executable capability is named and which the probe and slice invocations both consume, so the -// bytes that were verified are the bytes that run. +// A capability may be spent only inside with_verified_orca's private-copy lifetime. +// The archive is copied first and the copy is checked against the selected asset's digest. +// The callback can return only a process result, never the capability. Both probe and slice +// execute that same copy through AppImage's extract-and-run runtime, which removes its extraction. +// The private realization protects against replacing the caller's original archive. Like the +// printer payload bracket, it does not defend against a hostile process running as this user. type OrcaExecutable sole_constructor { program: NonEmptyStr } fn orca_executable_program(e: OrcaExecutable) -> NonEmptyStr { e.program } @@ -131,7 +100,7 @@ fn orca_executable_program(e: OrcaExecutable) -> NonEmptyStr { e.program } // cheapest Orca-shaped command there is, it needs no display, and it exits zero only if the runtime // closure resolved -- which is exactly the fact a would-be slicer needs before a slice is planned. fn orca_version_probe_command(executable: OrcaExecutable) -> ArgvCommand { - argv_command(program: uncataloged_program(invocation: executable.program), arguments: ["--version"]) + argv_command(program: uncataloged_program(invocation: executable.program), arguments: ["--appimage-extract-and-run", "--help"]) } // THE CLI, WHICH IS THE ONLY INTERFACE THIS REPOSITORY USES. The desktop application is the same @@ -147,6 +116,8 @@ fn orca_slice_to_project_command( argv_command( program: uncataloged_program(invocation: executable.program), arguments: [ + "--appimage-extract-and-run", + "--arrange", "1", "--orient", "0", "--ensure-on-bed", "--slice", "0", "--load-settings", concat(concat(printer_profile, ";"), process_profile), "--load-filaments", filament_profile, @@ -155,3 +126,28 @@ fn orca_slice_to_project_command( ], ) } + +// The caller's copy is never executed, and a failed digest cannot mint the capability. +fn with_verified_orca(appimage: FilePath, use: fn(OrcaExecutable) -> ProcessOutcome) -> ProcessOutcome { + let temp = shell.Mktemp.Dir() + if !temp.success { return ProcessRefused { exit_code: 1, stderr: "Cannot create private Orca realization" } } + let path = join([temp.path as String, "/", orca_slicer_linux_aarch64_appimage_sha256 as String, ".AppImage"], "") + let result = if !shell.Copy.File(source: appimage, destination: path as FilePath).success { + ProcessRefused { exit_code: 1, stderr: "Cannot copy Orca archive" } + } else { + match sha256sum_file_digest_via_shell(path: path as NonEmptyStr) { + Sha256FileDigestUnavailable { path: _, reason: r } => ProcessRefused { exit_code: 1, stderr: r as String } + Sha256FileDigest { digest: d } => + if (d.hex as String) != (orca_slicer_linux_aarch64_appimage_sha256 as String) { + ProcessRefused { exit_code: 1, stderr: "Orca archive differs from the selected upstream digest; not executed" } + } else { + match run_shell_command_capture(command: chmod_set_mode_command(chmod_program: chmod_path_resolved_program, mode: "0500", path: path), transport: LocalExec) { + ProcessRefused { exit_code: c, stderr: e } => ProcessRefused { exit_code: c, stderr: e } + _ => use(OrcaExecutable { program: path as NonEmptyStr }) + } + } + } + } + if shell.Remove.RecursiveForce(path: temp.path as FilePath).success { result } + else { ProcessRefused { exit_code: 1, stderr: concat("Orca realization cleanup failed at ", temp.path as String) } } +} diff --git a/dag/extdeps/tools/mosquitto_pub.dag b/dag/extdeps/tools/mosquitto_pub.dag index 46500464945..ed1afab2f96 100644 --- a/dag/extdeps/tools/mosquitto_pub.dag +++ b/dag/extdeps/tools/mosquitto_pub.dag @@ -2,6 +2,8 @@ module extdeps.tools.mosquitto_pub import extdeps.exec.program { ProgramIdentity, cataloged_program } +import extdeps.shell.exec { ProcessObservation } + import std.types { String, NonEmptyStr, Bool, Port, FilePath, List } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.version { VersionConstraint } @@ -124,13 +126,17 @@ fn mosquitto_publish_command( ) } -// The upstream synopsis exposes --help, not --version. Its first line identifies -// the client version; publishing is never used as a tool-availability probe. +// The upstream synopsis exposes --help, not --version. Its version banner may follow +// the description line; publishing is never used as a tool-availability probe. fn mosquitto_pub_help_command() -> ArgvCommand { argv_command(program: mosquitto_pub_program(), arguments: ["--help"]) } fn mosquitto_pub_help_supports_private_options(stdout: String) -> Bool { + any(split(s: stdout, delimiter: "\n"), line => mosquitto_pub_version_line_supported(stdout: line)) +} + +fn mosquitto_pub_version_line_supported(stdout: String) -> Bool { let prefix = "mosquitto_pub version " if !starts_with(s: stdout, prefix: prefix) { false } else { @@ -147,3 +153,10 @@ fn mosquitto_pub_help_supports_private_options(stdout: String) -> Bool { } } } + +// Measured on the installed 2.0.18 client: successful --help prints its banner but exits 1. +// Code 1 without that specific supported-version banner is still a refusal. +fn mosquitto_pub_help_observation_admitted(observation: ProcessObservation) -> Bool { + (observation.exit_code == 0 || observation.exit_code == 1) && + mosquitto_pub_help_supports_private_options(stdout: observation.stdout) +} diff --git a/dag/extdeps/tools/mosquitto_sub.dag b/dag/extdeps/tools/mosquitto_sub.dag new file mode 100644 index 00000000000..d8e5fbb5f1b --- /dev/null +++ b/dag/extdeps/tools/mosquitto_sub.dag @@ -0,0 +1,34 @@ +module extdeps.tools.mosquitto_sub + +import std.types { NonEmptyStr, String, FilePath } +import extdeps.exec.command { ArgvCommand, argv_command } +import extdeps.exec.program { ProgramIdentity, cataloged_program } +import extdeps.tools { CliTool, SourceApt } +import extdeps.apt { package_mosquitto_clients, apt_bin_dir } +import extdeps.tools.mosquitto_pub { mosquitto_pub_pinned_version } +import extdeps.oasis.mqtt { MqttEndpoint, MqttTopic } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "mosquitto.org/man/mosquitto_sub-1.html" } +} +fn mosquitto_sub_program() -> ProgramIdentity = cataloged_program(invocation: "mosquitto_sub") + +data mosquitto_sub_cli_tool: CliTool = CliTool { + program: mosquitto_sub_program(), + min_version: Present { value: mosquitto_pub_pinned_version }, + installable_via: [SourceApt { package: package_mosquitto_clients, bin_dir: apt_bin_dir }], +} + +data mosquitto_sub_options_file_name: NonEmptyStr = "mosquitto_sub" + +// One report, bounded by 15 seconds. TLS validates the same pinned issuer as the +// publisher; the machine serial selects the report topic. No command is published. +fn mosquitto_report_command(endpoint: MqttEndpoint, topic: MqttTopic, ca_bundle: FilePath) -> ArgvCommand { + argv_command(program: mosquitto_sub_program(), arguments: [ + "-h", endpoint.host as String, "-p", to_string(endpoint.port), + "--cafile", ca_bundle as String, "--insecure", "-t", topic as String, + "-R", "-C", "1", "-W", "15", + ]) +} diff --git a/dag/gunbc/auth/mosquitto_options_binding.dag b/dag/gunbc/auth/mosquitto_options_binding.dag index 3a8f36c68cf..bb820dce942 100644 --- a/dag/gunbc/auth/mosquitto_options_binding.dag +++ b/dag/gunbc/auth/mosquitto_options_binding.dag @@ -1,5 +1,7 @@ module gunbc.auth.mosquitto_options_binding +import extdeps.tools.mosquitto_sub { mosquitto_sub_options_file_name } + import std.types { NonEmptyStr, String, Bool, Secret, FilePath, List } import extdeps.shell import extdeps.filesystem.filesystem_io { Filesystem } @@ -124,3 +126,17 @@ fn place_mosquitto_options_privately( fn shred_mosquitto_options_binding(binding: MosquittoOptionsBinding) -> Bool { shell.Remove.RecursiveForce(path: binding.dir).success } + +// Both clients use the same upstream options grammar but distinct fixed filenames. +fn place_mosquitto_sub_options_privately(login: NonEmptyStr, password: Secret) -> MosquittoOptionsPlacement { + match place_mosquitto_options_privately(login: login, password: password) { + MosquittoOptionsPlaced { binding: b } => { + let source = join([b.dir as String, "/", mosquitto_pub_options_file_name as String], "") + let target = join([b.dir as String, "/", mosquitto_sub_options_file_name as String], "") + if shell.Copy.File(source: source as FilePath, destination: target as FilePath).success { + MosquittoOptionsPlaced { binding: b } + } else { mosquitto_options_write_refusal(removed_after_failure: shred_mosquitto_options_binding(binding: b)) } + } + other => other + } +} diff --git a/dag/gunbc/fleet/printer_operator_approval.dag b/dag/gunbc/fleet/printer_operator_approval.dag new file mode 100644 index 00000000000..1b732549891 --- /dev/null +++ b/dag/gunbc/fleet/printer_operator_approval.dag @@ -0,0 +1,123 @@ +module gunbc.fleet.printer_operator_approval + +import std.types { String, NonEmptyStr, Bool } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import std.measure { second } +import std.bytes { bytes_octets, utf8_encode_bytes } +import std.content_hash { sha256_hex_digest, as_content_hash_cryptographic } +import std.effect_grant { Execute, NamespacePosition, ServiceOpTree } +import std.scoped_authorization { AuthorizationRequest, AuthorizationScope, AttemptIdentity, AuthorizedAction } +import extdeps.crypto.sha2 { sha256_hex } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.languages.json.emit { json_object, json_kv, json_string, serialize_json } +import extdeps.languages.json.emit { JsonString, JsonNumber } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, json_object_unique_member, JsonMemberFound } +import extdeps.shell.exec { ProcessOutputPresent } +import gunbc.fleet.printer_report { report } +import gunbc.fleet_asset_identity { PhysicalAssetIdentity } +import gunbc.fleet.printer_workflow_run { select_printer, PrinterSelected, PrinterSelectionRefused, print_inputs_admitted, print_project_with_source } +import gunbc.fleet.printer_tool_readiness { printer_mqtt_tool_ready } +import gunbc.auth.access_token_source { read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable, OperatorSuppliedToken } +import gunbc.auth.approval_request_client { stored_request_from_authorization } +import gunbc.auth.approval_gate { + approval_instant_now, approval_expires_at, approval_file_stored_request, + ApprovalFiled, ApprovalFilingRefused, approval_poll_ticks, PollObservedAt, + PollClockUnreadable, PollBudgetExhausted, approval_gate_from_poll, + GateAdmitted, GateRefused, approval_refusal_text, +} + +// Local, explicitly operator-authenticated batch. It neither impersonates Actions nor +// modifies IAM. The usual signed ntfy request binds the selected device, project bytes, +// exact credential version and batch attempt. A persistent create-only marker is spent +// before any transfer/start; ambiguous outcomes cannot be replayed for this batch. +fn print_request(printer: PhysicalAssetIdentity, digest: String, version: String, attempt: String) -> AuthorizationRequest? { + let subject = serialize_json(v: json_object(members: [ + json_kv(key: "printer", value: json_string(s: printer as String)), + json_kv(key: "project_sha256", value: json_string(s: digest)), + json_kv(key: "credential_version", value: json_string(s: version)), + json_kv(key: "attempt", value: json_string(s: attempt)), + ])) + match sha256_hex_digest(hex: sha256_hex(message: bytes_octets(b: utf8_encode_bytes(s: subject)))) { + Absent => none + Present { value: hash } => Present { value: AuthorizationRequest { + escalation_id: concat("printer-start-", hash.hex as String) as NonEmptyStr, + scopes: [AuthorizationScope { + verb: Execute, + resource: NamespacePosition { tree: ServiceOpTree { service: "bambu-lan" }, path: [printer as String] }, + action: "gunbc.printer.project-file.start" as AuthorizedAction, + }], + subject: subject, + purpose: join(["Print one Altra mounting gauge on ", printer as String, ". Project SHA-256 ", digest, + "; credential version ", version, "; batch ", attempt, + ". Bed reported clear. Uses the operator's one-hour token; no IAM or credential changes. One start only."], "") as NonEmptyStr, + attempt: attempt as AttemptIdentity, + intent_hash: as_content_hash_cryptographic(digest: hash), + destructive: false, + } } + } +} + +fn run(printer: String, path: String, digest: String, version: String, attempt: String, bed_clear: String, batch_root: String) -> ProcessExit { + if path == "" || attempt == "" || batch_root == "" || !print_inputs_admitted(version: version, digest: digest, bed_clear: bed_clear, run_attempt: "1") { + return exit_failure(reason: "Print requires a path, digest, numeric credential version, attempt, persistent batch directory, and clear-bed confirmation") + } + if !printer_mqtt_tool_ready() { return exit_failure(reason: "MQTT tool is not ready; nothing requested or sent") } + match select_printer(name: printer) { + PrinterSelectionRefused => exit_failure(reason: "Unknown printer") + PrinterSelected { printer: p } => match read_supplied_access_token() { + SuppliedTokenUnavailable { cause: c } => exit_failure(reason: c as String) + SuppliedTokenReady { token: token } => match print_request(printer: p, digest: digest, version: version, attempt: attempt) { + Absent => exit_failure(reason: "Cannot identify print request") + Present { value: request } => match approval_instant_now() { + Absent => exit_failure(reason: "Cannot read clock") + Present { value: now } => { + let expires = approval_expires_at(issued: now, window: second(count: 900)) + let stored = stored_request_from_authorization(request: request, issued_at: now, expires_at: expires) + match approval_file_stored_request(stored: stored, body_file_name: concat(printer, "-approval.json"), submit_timeout: second(count: 30)) { + ApprovalFilingRefused { reason: r } => exit_failure(reason: r) + ApprovalFiled => match approval_poll_ticks(remaining: 90, cadence: second(count: 10), escalation_id: request.escalation_id, expires_at: expires) { + PollClockUnreadable => exit_failure(reason: "Cannot read approval clock") + PollBudgetExhausted { escalation_id: _, observed_at: _ } => exit_failure(reason: "Print approval did not arrive; nothing sent") + PollObservedAt { standing: s, observed_at: at } => match approval_gate_from_poll(poll: s, request: request, observed_at: at) { + GateRefused { cause: c } => exit_failure(reason: approval_refusal_text(cause: c)) + GateAdmitted { authorization: _ } => { + let live = report(printer: p, version: version as NonEmptyStr, token_source: OperatorSuppliedToken { token: token }) + let idle = match live { + ProcessOutputPresent { text: text } => + Filesystem.Write(path: join([batch_root, "/", printer, ".before-start.json"], ""), content: text).success && report_allows_start(text: text) + _ => false + } + if !idle { return exit_failure(reason: "Printer was not freshly observed idle/finished with no print error; nothing uploaded or started") } + let claim = Filesystem.WriteCreateNew(path: join([batch_root, "/", printer, ".start-claimed"], ""), content: request.subject) + if !claim.success { exit_failure(reason: "This printer's batch start is already claimed or its durable marker cannot be written; do not replay") } + else { print_project_with_source(printer: p, path: path, digest: digest, version: version as NonEmptyStr, token_source: OperatorSuppliedToken { token: token }) } + } + } + } + } + } + } + } + } + } +} + +fn report_allows_start(text: String) -> Bool { + match parse_json_document(s: text) { + JsonDocumentParsed { value: root } => match json_object_unique_member(v: root, key: "print") { + JsonMemberFound { value: p } => { + let idle = match json_object_unique_member(v: p, key: "gcode_state") { + JsonMemberFound { value: JsonString { value: state } } => state == "IDLE" || state == "FINISH" + _ => false + } + let clear = match json_object_unique_member(v: p, key: "print_error") { + JsonMemberFound { value: JsonNumber { lexeme: code } } => (code as String) == "0" + _ => false + } + idle && clear + } + _ => false + } + _ => false + } +} diff --git a/dag/gunbc/fleet/printer_operator_run.dag b/dag/gunbc/fleet/printer_operator_run.dag new file mode 100644 index 00000000000..b06e8f59c52 --- /dev/null +++ b/dag/gunbc/fleet/printer_operator_run.dag @@ -0,0 +1,24 @@ +module gunbc.fleet.printer_operator_run + +import std.types { String } +import std.process { ProcessExit, exit_failure } +import gunbc.fleet.printer_workflow_run { + select_printer, PrinterSelected, PrinterSelectionRefused, observe_credential_with_source, +} +import gunbc.auth.access_token_source { + read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable, OperatorSuppliedToken, +} + +// Explicit local operator route. It never labels an operator token as WIF or invents +// an Actions run. Observation only: reads an existing credential, prints no credential, +// changes no IAM or secret state, and sends nothing to the physical printer. +fn observe(printer: String) -> ProcessExit { + match select_printer(name: printer) { + PrinterSelectionRefused => exit_failure(reason: "Unknown printer") + PrinterSelected { printer: p } => match read_supplied_access_token() { + SuppliedTokenUnavailable { cause: c } => exit_failure(reason: c as String) + SuppliedTokenReady { token: t } => + observe_credential_with_source(printer: p, token_source: OperatorSuppliedToken { token: t }) + } + } +} diff --git a/dag/gunbc/fleet/printer_project_delivery.dag b/dag/gunbc/fleet/printer_project_delivery.dag index b639859d61f..cf164b5f0d5 100644 --- a/dag/gunbc/fleet/printer_project_delivery.dag +++ b/dag/gunbc/fleet/printer_project_delivery.dag @@ -55,9 +55,9 @@ import gunbc.fleet_printer_access { // DELIVERING A PROJECT TO A PRINTER, AND DELIBERATELY NOTHING ABOUT HOW IT WAS PRODUCED. // // THE INPUT IS A PAYLOAD, NOT A SLICE PLAN. product.printed_chassis.slicing_toolchain owns whether -// THIS repository may run OrcaSlicer; that question is currently answered "no" and its positive -// capability is uninhabited. Coupling delivery to it would make the transport unprovable behind a -// producer-side blocker, and would also be a category error: the printer does not care who sliced +// this repository may run OrcaSlicer. The verified-archive bracket now realizes that producer +// through product.printed_chassis.slice_run. Coupling delivery to a particular producer would +// still be a category error: the printer does not care who sliced // the file. A project that already exists -- sliced elsewhere, by a human, months ago -- is a // legitimate input here and says nothing false about this repository's slicing. // diff --git a/dag/gunbc/fleet/printer_report.dag b/dag/gunbc/fleet/printer_report.dag new file mode 100644 index 00000000000..c5eb7624b46 --- /dev/null +++ b/dag/gunbc/fleet/printer_report.dag @@ -0,0 +1,84 @@ +module gunbc.fleet.printer_report + +import std.types { String, NonEmptyStr, Secret } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.shell +import extdeps.shell.exec { ProcessOutcome, ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.exec.command { env_prefixed_command, LocalExec } +import extdeps.tools.mosquitto_sub { mosquitto_report_command } +import extdeps.printing.bambu_lab_lan_protocol { bambu_report_topic, bambu_lan_mqtt_endpoint, bambu_lan_mqtt_user } +import gunbc.command_runner { run_shell_command_capture } +import gunbc.fleet_asset_identity { PhysicalAssetIdentity } +import gunbc.fleet.printer_workflow_run { select_printer, PrinterSelected, PrinterSelectionRefused } +import gunbc.fleet.printer_project_delivery { printer_endpoint_address, PrinterAddressFound } +import gunbc.fleet.printer_job_start { + printer_serial, PrinterSerialFound, acquire_pinned_ca_bundle, CaBundlePinned, + CaBundleRefused, CaBundleRefusedResidueRemains, pinned_ca_bundle_path, +} +import gunbc.fleet_printer_access { printer_access_code_locus, PrinterAccessCodeDesiredLocusFound } +import gunbc.secret_provision { secret_ref_pin_version } +import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } +import gunbc.auth.access_token_source { AccessTokenSource, OperatorSuppliedToken, read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable } +import gunbc.auth.secret_ref_credential { fetch_secret_ref_credential, SecretCredentialReady } +import gunbc.auth.mosquitto_options_binding { + place_mosquitto_sub_options_privately, MosquittoOptionsPlaced, + MosquittoOptionsRefused, MosquittoOptionsRefusedResidueRemains, + shred_mosquitto_options_binding, mosquitto_options_env, +} + +// Read-only subscriber. Topic/endpoint/credential all join through the same fleet identity. +// It observes current state, not causality or an acknowledgement of a particular start. +fn report(printer: PhysicalAssetIdentity, version: NonEmptyStr, token_source: AccessTokenSource) -> ProcessOutcome { + if !sm_version_id_is_number(version: version as String) { return ProcessRefused { exit_code: 1, stderr: "Report requires an exact credential version" } } + match printer_serial(printer: printer) { + PrinterSerialFound { serial: serial } => match printer_endpoint_address(printer: printer) { + PrinterAddressFound { address: address } => match printer_access_code_locus(printer: printer) { + PrinterAccessCodeDesiredLocusFound { locus: locus } => match fetch_secret_ref_credential(secret_ref: secret_ref_pin_version(ref: locus.secret, version: version), token_source: token_source) { + SecretCredentialReady { credential: c, resolved_version: _ } => report_with_credential(serial: serial, address: address, credential: c) + _ => ProcessRefused { exit_code: 1, stderr: "Cannot read the requested printer credential" } + } + _ => ProcessRefused { exit_code: 1, stderr: "Printer credential locus is absent or ambiguous" } + } + _ => ProcessRefused { exit_code: 1, stderr: "Printer address is absent or ambiguous" } + } + _ => ProcessRefused { exit_code: 1, stderr: "Printer serial is absent or ambiguous" } + } +} + +fn report_with_credential(serial: NonEmptyStr, address: NonEmptyStr, credential: Secret) -> ProcessOutcome { + match acquire_pinned_ca_bundle() { + CaBundleRefused { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } + CaBundleRefusedResidueRemains { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } + CaBundlePinned { bundle: bundle, workspace: workspace } => { + let result = match place_mosquitto_sub_options_privately(login: bambu_lan_mqtt_user, password: credential) { + MosquittoOptionsRefused { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } + MosquittoOptionsRefusedResidueRemains { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } + MosquittoOptionsPlaced { binding: b } => { + let outcome = run_shell_command_capture(command: env_prefixed_command(bindings: mosquitto_options_env(binding: b), command: mosquitto_report_command( + endpoint: bambu_lan_mqtt_endpoint(host: address), topic: bambu_report_topic(serial: serial), ca_bundle: pinned_ca_bundle_path(bundle: bundle))), transport: LocalExec) + if shred_mosquitto_options_binding(binding: b) { outcome } + else { ProcessRefused { exit_code: 1, stderr: "Printer report credential cleanup failed" } } + } + } + if shell.Remove.RecursiveForce(path: workspace).success { result } + else { ProcessRefused { exit_code: 1, stderr: "Printer report CA workspace cleanup failed" } } + } + } +} + +fn run(printer: String, version: String, output: String) -> ProcessExit { + match select_printer(name: printer) { + PrinterSelectionRefused => exit_failure(reason: "Unknown printer") + PrinterSelected { printer: p } => match read_supplied_access_token() { + SuppliedTokenUnavailable { cause: c } => exit_failure(reason: c as String) + SuppliedTokenReady { token: t } => match report(printer: p, version: version as NonEmptyStr, token_source: OperatorSuppliedToken { token: t }) { + ProcessRefused { exit_code: _, stderr: e } => exit_failure(reason: e) + ProcessOutputAbsent => exit_failure(reason: "No printer report received") + ProcessOutputPresent { text: text } => + if Filesystem.Write(path: output, content: text).success { ExitSuccess } + else { exit_failure(reason: "Cannot retain printer report") } + } + } + } +} diff --git a/dag/gunbc/fleet/printer_tool_readiness.dag b/dag/gunbc/fleet/printer_tool_readiness.dag index c652a5d7390..f45867cf3ed 100644 --- a/dag/gunbc/fleet/printer_tool_readiness.dag +++ b/dag/gunbc/fleet/printer_tool_readiness.dag @@ -1,19 +1,18 @@ module gunbc.fleet.printer_tool_readiness import std.types { Bool } -import extdeps.tools.mosquitto_pub { mosquitto_pub_cli_tool, mosquitto_pub_help_command, mosquitto_pub_help_supports_private_options } +import extdeps.tools.mosquitto_pub { mosquitto_pub_cli_tool, mosquitto_pub_help_command, mosquitto_pub_help_observation_admitted } import gunbc.host_cli_dependency { observe_host_cli_dependency, HostCliDependencyPresent, HostCliDependencyAbsent } -import gunbc.command_runner { run_shell_command_capture } +import gunbc.command_runner { run_shell_command_observe_outcome } import extdeps.exec.command { LocalExec } -import extdeps.shell.exec { ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } +import extdeps.shell.exec { ProcessRan, ProcessNotSpawned } fn printer_mqtt_tool_ready() -> Bool { match observe_host_cli_dependency(tool: mosquitto_pub_cli_tool) { HostCliDependencyAbsent { absence: _ } => false - HostCliDependencyPresent => match run_shell_command_capture(command: mosquitto_pub_help_command(), transport: LocalExec) { - ProcessOutputPresent { text: t } => mosquitto_pub_help_supports_private_options(stdout: t) - ProcessOutputAbsent => false - ProcessRefused { exit_code: _, stderr: _ } => false + HostCliDependencyPresent => match run_shell_command_observe_outcome(command: mosquitto_pub_help_command(), transport: LocalExec) { + ProcessRan { observation: o } => mosquitto_pub_help_observation_admitted(observation: o) + ProcessNotSpawned { program: _, cause: _ } => false } } } diff --git a/dag/gunbc/fleet/printer_workflow_run.dag b/dag/gunbc/fleet/printer_workflow_run.dag index b6cf65c2c7b..2ea46224f25 100644 --- a/dag/gunbc/fleet/printer_workflow_run.dag +++ b/dag/gunbc/fleet/printer_workflow_run.dag @@ -7,14 +7,14 @@ import std.process { ProcessExit, ExitSuccess, exit_failure } import std.content_hash { sha256_hex_digest } import std.decimal { decimal_digits_only } import extdeps.filesystem.filesystem_io { Filesystem } -import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } +import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number, sm_resolved_version_id, SmVersionIdProjected, SmVersionIdNotAVersionResource } import gunbc.secret_provision { secret_ref_pin_version } import gunbc.fleet_asset_identity { PhysicalAssetIdentity, printer_01, printer_02 } import gunbc.fleet_printer_access { printer_access_code_locus, PrinterAccessCodeDesiredLocusFound, PrinterAccessCodeLocusUnknown, PrinterAccessCodeLocusConflict, PrinterExistingVersion, } -import gunbc.auth.access_token_source { WorkloadIdentityToken } +import gunbc.auth.access_token_source { AccessTokenSource, WorkloadIdentityToken } import gunbc.auth.secret_ref_credential { fetch_secret_ref_credential, SecretCredentialReady, SecretCredentialAccessUpsertRequired, SecretCredentialWireDecodeRefused, SecretCredentialFetchRefused, SecretCredentialResolvedVersionMismatch, @@ -53,6 +53,7 @@ fn print_inputs_admitted(version: String, digest: String, bed_clear: String, run fn write_receipt(printer: PhysicalAssetIdentity, standing: String, version: String, remote: String, digest: String) -> ProcessExit { let content = serialize_json(v: json_object(members: [ + json_kv(key: "execution_source", value: json_string(s: if actions_variable_trimmed(name: "GITHUB_RUN_ID") == "" { "operator" } else { "github-actions" })), json_kv(key: "printer", value: json_string(s: printer as String)), json_kv(key: "standing", value: json_string(s: standing)), json_kv(key: "credential_version", value: json_string(s: version)), @@ -69,14 +70,21 @@ fn write_receipt(printer: PhysicalAssetIdentity, standing: String, version: Stri // Discovery reads latest once and records the provider's numeric identity, never the code. // Starting is a different operation and requires that exact numeric pin. fn observe_credential(printer: PhysicalAssetIdentity) -> ProcessExit { + observe_credential_with_source(printer: printer, token_source: WorkloadIdentityToken) +} + +fn observe_credential_with_source(printer: PhysicalAssetIdentity, token_source: AccessTokenSource) -> ProcessExit { match printer_access_code_locus(printer: printer) { PrinterAccessCodeLocusUnknown { printer: _ } => exit_failure(reason: "Printer credential locus is absent") PrinterAccessCodeLocusConflict { printer: _, matches: _ } => exit_failure(reason: "Printer credential locus is ambiguous") PrinterAccessCodeDesiredLocusFound { locus: locus } => - match fetch_secret_ref_credential(secret_ref: secret_ref_pin_version(ref: locus.secret, version: "latest"), token_source: WorkloadIdentityToken) { + match fetch_secret_ref_credential(secret_ref: secret_ref_pin_version(ref: locus.secret, version: "latest"), token_source: token_source) { SecretCredentialReady { credential: c, resolved_version: version } => if (c as String) == "" { exit_failure(reason: "Printer credential is empty") } - else { write_receipt(printer: printer, standing: "credential-observed; printer not contacted", version: version as String, remote: "", digest: "") } + else { match sm_resolved_version_id(identity: version) { + SmVersionIdProjected { version: v } => write_receipt(printer: printer, standing: "credential-observed; printer not contacted", version: v as String, remote: "", digest: "") + SmVersionIdNotAVersionResource { supplied: _ } => exit_failure(reason: "Credential version identity cannot be projected") + } } SecretCredentialAccessUpsertRequired { plan: _ } => exit_failure(reason: "Printer federation needs the ntfy-approved IAM converge") SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => exit_failure(reason: "Printer credential response did not decode") SecretCredentialFetchRefused { reason: r } => exit_failure(reason: r as String) @@ -92,6 +100,10 @@ fn print_project(printer: PhysicalAssetIdentity, path: String, digest: String, v } fn print_project_with_tools(printer: PhysicalAssetIdentity, path: String, digest: String, version: NonEmptyStr) -> ProcessExit { + print_project_with_source(printer: printer, path: path, digest: digest, version: version, token_source: WorkloadIdentityToken) +} + +fn print_project_with_source(printer: PhysicalAssetIdentity, path: String, digest: String, version: NonEmptyStr, token_source: AccessTokenSource) -> ProcessExit { match identify_project_payload(payload: PrinterProjectPayload { local_path: path as NonEmptyStr }) { PayloadIdentificationRefused { local_path: _, cause: c } => exit_failure(reason: c as String) PayloadIdentificationRefusedResidueRemains { local_path: _, residue_path: residue, cause: c } => exit_failure(reason: join(["Payload residue remains at ", residue as String, ": ", c as String], "")) @@ -100,10 +112,10 @@ fn print_project_with_tools(printer: PhysicalAssetIdentity, path: String, digest let cleaned = abandon_payload(payload: payload) exit_failure(reason: if cleaned { "Project digest differs from the requested print; nothing uploaded" } else { "Project digest differs; private payload cleanup also failed" }) } else { - match deliver_project_to_printer_with_access(printer: printer, payload: payload, token_source: WorkloadIdentityToken, access: PrinterExistingVersion { version: version }) { + match deliver_project_to_printer_with_access(printer: printer, payload: payload, token_source: token_source, access: PrinterExistingVersion { version: version }) { ProjectDelivered { receipt: receipt } => { let remote = receipt_remote_name(r: receipt) - match start_print_on_printer_with_access(printer: printer, remote_name: remote, token_source: WorkloadIdentityToken, access: PrinterExistingVersion { version: version }) { + match start_print_on_printer_with_access(printer: printer, remote_name: remote, token_source: token_source, access: PrinterExistingVersion { version: version }) { PrintStartPublished { receipt: _ } => write_receipt(printer: printer, standing: "start-published; RUNNING not yet observed", version: version as String, remote: remote as String, digest: digest) PrintStartRefused { printer: _, cause: c } => exit_failure(reason: c as String) PrintStartRefusedResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Start refused; credential residue remains: ", c as String)) diff --git a/dag/gunbc/product/printed_chassis/slice_run.dag b/dag/gunbc/product/printed_chassis/slice_run.dag new file mode 100644 index 00000000000..d0d38fd98e2 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/slice_run.dag @@ -0,0 +1,56 @@ +module product.printed_chassis.slice_run + +import std.types { String, FilePath, List } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.printing.orca_slicer { with_verified_orca, orca_version_probe_command, orca_slice_to_project_command } +import extdeps.exec.command { LocalExec } +import extdeps.shell.exec { ProcessRefused } +import gunbc.command_runner { run_shell_command_capture } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.languages.json.emit { JsonKeyValue, JsonObject, json_object, json_kv, json_string, serialize_json } + +// Selected slicing settings, not a claim of live printer telemetry. The 2 mm brim +// keeps the 170.94 mm gauge inside the 180 mm envelope without scaling the gauge. +fn gauge_settings_members() -> List { + [ + json_kv(key: "type", value: json_string(s: "process")), + json_kv(key: "from", value: json_string(s: "user")), + json_kv(key: "name", value: json_string(s: "Altra gauge 0.20 mm PLA")), + json_kv(key: "curr_bed_type", value: json_string(s: "Textured PEI Plate")), + json_kv(key: "brim_type", value: json_string(s: "outer_only")), + json_kv(key: "brim_width", value: json_string(s: "2")), + json_kv(key: "skirt_loops", value: json_string(s: "0")), + json_kv(key: "enable_support", value: json_string(s: "0")), + ] +} + +// Runs the exact pinned upstream artifact over explicitly supplied model and profiles. +// A successful process result must still be followed by project/G-code readback before printing. +fn run(appimage: String, model: String, output: String, machine: String, filament: String, process: String) -> ProcessExit { + let settings = concat(output, ".settings.json") + let original = Filesystem.Read(path: process) + if !original.success { return exit_failure(reason: "Cannot read base process profile") } + let overrides = gauge_settings_members() + let content = match parse_json_document(s: original.content) { + JsonDocumentParsed { value: JsonObject { members: members } } => + serialize_json(v: json_object(members: concat(filter(members, m => !any(overrides, o => o.key == m.key)), overrides))) + _ => "" + } + if content == "" || !Filesystem.Write(path: settings, content: content).success { + return exit_failure(reason: "Cannot parse or write gauge process profile") + } + match with_verified_orca(appimage: appimage as FilePath, use: executable => { + let probe = run_shell_command_capture(command: orca_version_probe_command(executable: executable), transport: LocalExec) + match probe { + ProcessRefused { exit_code: _, stderr: _ } => probe + _ => run_shell_command_capture(command: orca_slice_to_project_command( + executable: executable, input_model: model, output_project: output, + printer_profile: machine, filament_profile: filament, process_profile: settings, + ), transport: LocalExec) + } + }) { + ProcessRefused { exit_code: _, stderr: e } => exit_failure(reason: e) + _ => ExitSuccess + } +} diff --git a/dag/gunbc/product/printed_chassis/slicing_toolchain.dag b/dag/gunbc/product/printed_chassis/slicing_toolchain.dag index b6cd99d7c23..0d5bb58ea31 100644 --- a/dag/gunbc/product/printed_chassis/slicing_toolchain.dag +++ b/dag/gunbc/product/printed_chassis/slicing_toolchain.dag @@ -12,7 +12,10 @@ import extdeps.printing.orca_slicer { orca_slice_to_project_command, OrcaExecutable, orca_version_probe_command, } -// WHERE THE MESH-TO-MACHINE ROUTE STOPS TODAY, DECLARED RATHER THAN DISCOVERED AT RUN TIME. +// Direct-host ABI and execution evidence. Historical refusal observations below retain their +// original hosts/environments. On 2026-10-03 slice_run executed the pinned archive successfully +// after the system packages supplied its runtime closure; with_verified_orca owns the private +// archive lifetime and prevents a caller's mutable input path becoming the executable authority. // // product.printed_chassis.export_profile ends at an STL. A Bambu Lab A1 mini in LAN Mode consumes a // sliced project, so exactly one step stands between a verified mesh and a printable artifact, and diff --git a/dag/test/claim/printer_operator_approval_witness_test.dag b/dag/test/claim/printer_operator_approval_witness_test.dag new file mode 100644 index 00000000000..5bfdf8ecb60 --- /dev/null +++ b/dag/test/claim/printer_operator_approval_witness_test.dag @@ -0,0 +1,42 @@ +module test.claim.printer_operator_approval_witness_test + +import extdeps.shell.exec { ProcessObservation } +import extdeps.tools.mosquitto_pub { mosquitto_pub_help_observation_admitted } + +import std.types { Bool } +import gunbc.fleet.printer_operator_approval { print_request, report_allows_start } +import gunbc.fleet_asset_identity { printer_01, printer_02 } + +test fn approval_changes_with_device_bytes_version_or_attempt() -> Bool { + let base = print_request(printer: printer_01, digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", version: "1", attempt: "batch-a") + let variants = [ + print_request(printer: printer_02, digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", version: "1", attempt: "batch-a"), + print_request(printer: printer_01, digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", version: "1", attempt: "batch-a"), + print_request(printer: printer_01, digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", version: "2", attempt: "batch-a"), + print_request(printer: printer_01, digest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", version: "1", attempt: "batch-b"), + ] + match base { + Absent => false + Present { value: original } => all(variants, v => match v { + Absent => false + Present { value: changed } => original.escalation_id != changed.escalation_id + }) + } +} + +test fn only_explicit_idle_without_print_error_admits_start() -> Bool { + report_allows_start(text: "{\"print\":{\"gcode_state\":\"FINISH\",\"print_error\":0}}") && + !report_allows_start(text: "{\"print\":{\"gcode_state\":\"RUNNING\",\"print_error\":0}}") && + !report_allows_start(text: "{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":1}}") && + !report_allows_start(text: "{\"print\":{\"gcode_state\":\"IDLE\"}}") && + !report_allows_start(text: "{}") +} + + +test fn help_exit_one_needs_the_actual_supported_version_banner() -> Bool { + mosquitto_pub_help_observation_admitted(observation: ProcessObservation { + exit_code: 1, stdout: "mosquitto_pub is a simple mqtt client.\nmosquitto_pub version 2.0.18 running on libmosquitto 2.0.18.\n", stderr: "", + }) && + !mosquitto_pub_help_observation_admitted(observation: ProcessObservation { exit_code: 1, stdout: "", stderr: "failed" }) && + !mosquitto_pub_help_observation_admitted(observation: ProcessObservation { exit_code: 2, stdout: "mosquitto_pub version 2.0.18", stderr: "" }) +} diff --git a/dag/test/claim/printer_workflow_witness_test.dag b/dag/test/claim/printer_workflow_witness_test.dag index 44af5e10457..f62f6c63840 100644 --- a/dag/test/claim/printer_workflow_witness_test.dag +++ b/dag/test/claim/printer_workflow_witness_test.dag @@ -30,7 +30,7 @@ test fn w_printer_federation_rejects_other_workflow_branch_and_environment() -> } test fn w_tool_probe_requires_mosquitto_version_two_or_newer() -> Bool { - mosquitto_pub_help_supports_private_options(stdout: "mosquitto_pub version 2.0.18 running on libmosquitto 2.0.18.") && + mosquitto_pub_help_supports_private_options(stdout: "mosquitto_pub is a simple mqtt client that will publish a message on a single topic and exit.\nmosquitto_pub version 2.0.18 running on libmosquitto 2.0.18.") && !mosquitto_pub_help_supports_private_options(stdout: "mosquitto_pub version 1.6.12 running on libmosquitto 1.6.12.") && !mosquitto_pub_help_supports_private_options(stdout: "") && !mosquitto_pub_help_supports_private_options(stdout: "unrelated tool 2.0") diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 16201f3cb5f..09d57288989 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -126,9 +126,11 @@ parts differ, it is not a printer-to-printer calibration comparison. Source-iden hashes are staged under `/home/briansrls/print-prep-2026-10-03/two-printer-batch/`, one directory per printer. Material/colour reports are now modeled: Creality PLA, white on printer-01 and black on printer-02. Physical spool identity and current bed-clear state are not established by those reports. -Nozzle/plate reconciliation and slicing are pending. -The routine workload-identity entry point is implemented in the migration below; deployment and -final slicing remain pending. No printer start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. +The selected slicing profile is the A1 mini 0.4 mm nozzle, Generic PLA and Textured PEI plate; +this is a recorded process choice, not a claim of current printer telemetry. +The routine workload-identity entry point is implemented in the migration below; its deployment +remains pending. The operator-token batch route and live progress are recorded separately below. +No printer start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. ### Printer authentication migration to the ntfy workflow @@ -169,6 +171,30 @@ start-replay regression tests passed. The canonical artifact gate regenerated th structure assertions and shell syntax checks passed. These are source checks, not live deployment or printer observations. +### Operator-token batch execution — 2026-10-03 + +The operator supplied a one-hour GCP token and asked to print before the migration lands. For this +batch, `printer_operator_approval.run` uses the existing `OperatorSuppliedToken` capability and the +shared signed ntfy approval gate. It does not relabel that token as WIF, invent an Actions run, or +change IAM/secret versions. The permanent main-pinned GitHub route remains separate. + +The approval binds printer, project SHA-256, exact credential version and batch attempt. After the +live approval it subscribes to a fresh report and requires IDLE/FINISH with zero print error, then +creates a persistent per-printer start marker before upload/start. A refused or ambiguous start is +never automatically replayed. `printer_report` reads telemetry through the same fleet identity, +exact credential and pinned CA; its report is current-state evidence, not causal acknowledgement. + +Printer-01 credential version 1 was read successfully through the modeled client. Holes 3–4 +were sliced successfully by Orca 2.4.2, with ZIP and embedded G-code checksum checks, one plate, +identity scaling, 15 layers/3 mm height, and bounds including the brim within 180 mm. Selected +settings: PLA, 220 °C nozzle, 65 °C textured bed, 0.20 mm layers, 2 mm outer brim. Estimated +19m22s and 6.99 g. Project SHA-256: +`28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636af701580e`. + +The isolated srv1 execution directory is `/home/briansrls/print-run-2026-10-03`; credentials live +only in its owner-only private directory and are not repository artifacts. The first approval run +and second slice are pending at this record. No physical start or RUNNING observation is claimed. + ### Local preparation environment The isolated worktree is `/home/briansrls/.worktrees/gunbc/3d-printing-2026-10-03`. @@ -188,11 +214,10 @@ Local preparation artifacts are in `/home/briansrls/print-prep-2026-10-03`: The preserved OrcaSlicer 2.4.2 AppImage at `/tmp/orca.AppImage` matches the digest recorded in `extdeps.printing.orca_slicer`. Its extracted CLI at `/tmp/orcax/squashfs-root/AppRun` now launches after installing its missing OpenGL/WebKitGTK runtime dependencies. `--help` and model inspection -work. The upstream CLI refuses `--version` (despite the current modeled probe naming it); use the -recorded help output as this session's executable observation, not a claimed successful version probe. -The model's sealed positive Orca capability remains uninhabited; these local tool observations do -not claim to repair that admission path. Final slicing and printer actuation are separate outstanding -steps after the operator supplies current setup and gauge results. +work. The modeled probe now uses the supported `--help` interface. `with_verified_orca` copies +and hashes the pinned AppImage in a private realization, runs probe and slice inside its callback, +and removes the realization before returning. `slice_run.run` uses it to produce the gauge project; +its success establishes slicing, not an observed physical print. ### Gauge observations to collect today From 0da75efff266f7f73a613105ffc1fed96e5dee7a Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 21:50:54 +0000 Subject: [PATCH 04/24] Route approval submissions to the active store writer --- dag/gunbc/auth/approval_request_client.dag | 24 ++++++++++++++----- dag/gunbc/fleet/printer_report.dag | 9 ++++++- .../approval_request_client_witness_test.dag | 13 +++++++--- ...printer_operator_approval_witness_test.dag | 7 ++++++ docs/plans/printed-chassis-program.md | 13 ++++++++-- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index f7aceb0336d..d3d0e253065 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -1,5 +1,8 @@ module gunbc.auth.approval_request_client +import gunbc.auth.approval_cutover_posture { ApprovalCutoverPosture, approval_cutover_posture, RoadmapServesAndWrites, BrokerServesAndWrites, BrokerOwnsAlone } +import gunbc.auth.approval_broker_endpoint { approval_broker_loopback_base_url, approval_broker_listen_port, approval_broker_submit_path } + import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.scoped_authorization { AuthorizationRequest } import extdeps.crypto.mac { @@ -18,14 +21,23 @@ import gunbc.auth.approval_request_submission { import gunbc.fleet_posix_accounts { fleet_operator_email } import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } -data approval_request_submit_path: NonEmptyStr = "/approvals" +data approval_request_submit_path: NonEmptyStr = approval_broker_submit_path + +// Local submissions must follow the same cutover posture as writer authority. The +// roadmap endpoint deliberately refuses store writes after the broker takes ownership. +fn approval_loopback_origin_for(posture: ApprovalCutoverPosture) -> NonEmptyStr { + match posture { + RoadmapServesAndWrites => { + let instance = srv1_live_dashboard_instance() + concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) as NonEmptyStr + } + BrokerServesAndWrites => approval_broker_loopback_base_url(port: approval_broker_listen_port) + BrokerOwnsAlone => approval_broker_loopback_base_url(port: approval_broker_listen_port) + } +} fn approval_loopback_origin() -> NonEmptyStr { - let instance = srv1_live_dashboard_instance() - concat( - concat("http://", instance.listen_host as String), - concat(":", to_string(instance.listen_port)), - ) as NonEmptyStr + approval_loopback_origin_for(posture: approval_cutover_posture) } fn approval_submit_url() -> NonEmptyStr { diff --git a/dag/gunbc/fleet/printer_report.dag b/dag/gunbc/fleet/printer_report.dag index c5eb7624b46..758c1e5904b 100644 --- a/dag/gunbc/fleet/printer_report.dag +++ b/dag/gunbc/fleet/printer_report.dag @@ -9,7 +9,7 @@ import extdeps.exec.command { env_prefixed_command, LocalExec } import extdeps.tools.mosquitto_sub { mosquitto_report_command } import extdeps.printing.bambu_lab_lan_protocol { bambu_report_topic, bambu_lan_mqtt_endpoint, bambu_lan_mqtt_user } import gunbc.command_runner { run_shell_command_capture } -import gunbc.fleet_asset_identity { PhysicalAssetIdentity } +import gunbc.fleet_asset_identity { PhysicalAssetIdentity, printer_01, printer_02 } import gunbc.fleet.printer_workflow_run { select_printer, PrinterSelected, PrinterSelectionRefused } import gunbc.fleet.printer_project_delivery { printer_endpoint_address, PrinterAddressFound } import gunbc.fleet.printer_job_start { @@ -82,3 +82,10 @@ fn run(printer: String, version: String, output: String) -> ProcessExit { } } } + +fn observe_both(version: String, directory: String) -> ProcessExit { + match run(printer: printer_01 as String, version: version, output: concat(directory, "/printer-01.json")) { + ExitSuccess => run(printer: printer_02 as String, version: version, output: concat(directory, "/printer-02.json")) + other => other + } +} diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index 721a546e382..258d4160bb6 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -1,9 +1,11 @@ module test.claim.approval_request_client_witness_test +import gunbc.auth.approval_cutover_posture { approval_cutover_posture, RoadmapServesAndWrites, BrokerServesAndWrites, BrokerOwnsAlone } +import gunbc.auth.approval_broker_endpoint { approval_broker_loopback_base_url, approval_broker_listen_port } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, NonEmptyStr, String } import gunbc.auth.approval_request_client { - approval_submit_url, approval_status_url, approval_loopback_origin, approval_request_submit_path, + approval_submit_url, approval_status_url, approval_loopback_origin, approval_loopback_origin_for, approval_request_submit_path, approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, StoredApprovalRequest, } @@ -32,10 +34,15 @@ fn fixture_stored() -> StoredApprovalRequest { } } -test fn submit_and_status_urls_follow_the_srv1_instance_listen_bind() -> Bool { +test fn submit_and_status_urls_follow_the_writer_cutover() -> Bool { let instance = srv1_live_dashboard_instance() let origin = approval_loopback_origin() as String - origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + let roadmap = concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + let broker = approval_broker_loopback_base_url(port: approval_broker_listen_port) + approval_loopback_origin_for(posture: RoadmapServesAndWrites) as String == roadmap + && approval_loopback_origin_for(posture: BrokerServesAndWrites) == broker + && approval_loopback_origin_for(posture: BrokerOwnsAlone) == broker + && origin == (approval_loopback_origin_for(posture: approval_cutover_posture) as String) && approval_submit_url() as String == concat(origin, approval_request_submit_path as String) && approval_status_url(escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") } diff --git a/dag/test/claim/printer_operator_approval_witness_test.dag b/dag/test/claim/printer_operator_approval_witness_test.dag index 5bfdf8ecb60..82b0cc5b081 100644 --- a/dag/test/claim/printer_operator_approval_witness_test.dag +++ b/dag/test/claim/printer_operator_approval_witness_test.dag @@ -3,6 +3,8 @@ module test.claim.printer_operator_approval_witness_test import extdeps.shell.exec { ProcessObservation } import extdeps.tools.mosquitto_pub { mosquitto_pub_help_observation_admitted } +import test.claim.approval_request_client_witness_test { submit_and_status_urls_follow_the_writer_cutover } + import std.types { Bool } import gunbc.fleet.printer_operator_approval { print_request, report_allows_start } import gunbc.fleet_asset_identity { printer_01, printer_02 } @@ -40,3 +42,8 @@ test fn help_exit_one_needs_the_actual_supported_version_banner() -> Bool { !mosquitto_pub_help_observation_admitted(observation: ProcessObservation { exit_code: 1, stdout: "", stderr: "failed" }) && !mosquitto_pub_help_observation_admitted(observation: ProcessObservation { exit_code: 2, stdout: "mosquitto_pub version 2.0.18", stderr: "" }) } + + +test fn approval_submission_follows_the_store_writer() -> Bool { + submit_and_status_urls_follow_the_writer_cutover() +} diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 09d57288989..03b1e55c31b 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -192,8 +192,17 @@ settings: PLA, 220 °C nozzle, 65 °C textured bed, 0.20 mm layers, 2 mm outer b `28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636af701580e`. The isolated srv1 execution directory is `/home/briansrls/print-run-2026-10-03`; credentials live -only in its owner-only private directory and are not repository artifacts. The first approval run -and second slice are pending at this record. No physical start or RUNNING observation is claimed. +only in owner-only private staging files and are not repository artifacts. Holes 4–5 also sliced +and passed the same checks: 15 layers, 13m48s, 3.67 g, SHA-256 +`a8f434e80014b6a316d30665b372bd0df3e858a68a2ff04a5fe4f50031d68535`. + +The shared approval client now follows the declared writer cutover to the approval broker on +8085; the old roadmap endpoint refused writes as designed. Both requests were filed and reached +approval polling. Four operator-route witnesses passed, including all three writer postures. +Fresh read-only reports showed printer-01 IDLE/error 0 and printer-02 IDLE/error 83902511. +Both reported PLA in AMS tray 0 (physical slot 1), while the existing start command selects the +external spool. Execution is held before upload pending confirmation of filament feed and the +printer-02 screen error. No start marker, physical start, or RUNNING observation is claimed. ### Local preparation environment From 243f5878b480e893c08ff11276dc19eca096cee5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:00:42 +0000 Subject: [PATCH 05/24] Select confirmed AMS slot one for both printer gauges --- dag/extdeps/printing/bambu_lab_lan_protocol.dag | 10 +++++----- dag/gunbc/fleet/printer_operator_approval.dag | 3 ++- .../claim/printer_job_start_witness_test.dag | 9 ++++----- .../printer_operator_approval_witness_test.dag | 7 +++++++ docs/plans/printed-chassis-program.md | 17 +++++++++++++++-- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/dag/extdeps/printing/bambu_lab_lan_protocol.dag b/dag/extdeps/printing/bambu_lab_lan_protocol.dag index ae9e1cbc22f..481e3b7789a 100644 --- a/dag/extdeps/printing/bambu_lab_lan_protocol.dag +++ b/dag/extdeps/printing/bambu_lab_lan_protocol.dag @@ -5,7 +5,7 @@ import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } import extdeps.uri { Uri, Https } import extdeps.oasis.mqtt { MqttEndpoint, MqttTopic, MqttOverTls, MqttQos, ExactlyOnce, AtLeastOnce } -import extdeps.languages.json.emit { JsonValue, JsonKeyValue, json_kv, json_object, json_string, json_bool, serialize_json } +import extdeps.languages.json.emit { JsonValue, JsonKeyValue, json_kv, json_object, json_string, json_bool, json_array, json_int, serialize_json } // THE LAN CONTROL PROTOCOL OF BAMBU LAB PRINTERS IN LAN-ONLY MODE, modeled because the delivery leg // already speaks FTPS and the leg that STARTS a job speaks MQTT -- and that second leg was @@ -69,9 +69,8 @@ fn bambu_lan_mqtt_endpoint(host: NonEmptyStr) -> MqttEndpoint { // meaningless unless delivery has completed, and the sequencing is a real dependency rather than a // convention. // -// use_ams IS FALSE BECAUSE THESE MACHINES HAVE NO AMS BOUND TO THIS PRINT. The operator's constraint -// is one filament per printer -- printer-01 white, printer-02 black -- which is exactly what the -// design wants, since the logo is a slot-in insert rather than a multi-colour feature. +// Operator confirmed AMS physical slot 1 on both printers on 2026-10-03. +// The single project filament maps to zero-indexed tray 0. type BambuPrintStartCommand { file_url: NonEmptyStr plate_gcode: NonEmptyStr @@ -96,7 +95,7 @@ fn bambu_start_command(remote_name: NonEmptyStr) -> BambuPrintStartCommand { file_url: bambu_sd_file_url(remote_name: remote_name), plate_gcode: bambu_first_plate_gcode, subtask_name: remote_name, - use_ams: false, + use_ams: true, bed_leveling: true, } } @@ -155,6 +154,7 @@ fn bambu_start_payload_json(command: BambuPrintStartCommand, sequence_id: NonEmp json_kv(key: "url", value: json_string(s: command.file_url as String)), json_kv(key: "subtask_name", value: json_string(s: command.subtask_name as String)), json_kv(key: "use_ams", value: json_bool(b: command.use_ams)), + json_kv(key: "ams_mapping", value: json_array(elements: [json_int(n: 0)])), json_kv(key: "bed_leveling", value: json_bool(b: command.bed_leveling)), json_kv(key: "flow_cali", value: json_bool(b: false)), json_kv(key: "vibration_cali", value: json_bool(b: true)), diff --git a/dag/gunbc/fleet/printer_operator_approval.dag b/dag/gunbc/fleet/printer_operator_approval.dag index 1b732549891..e121f62b0ac 100644 --- a/dag/gunbc/fleet/printer_operator_approval.dag +++ b/dag/gunbc/fleet/printer_operator_approval.dag @@ -35,6 +35,7 @@ fn print_request(printer: PhysicalAssetIdentity, digest: String, version: String json_kv(key: "printer", value: json_string(s: printer as String)), json_kv(key: "project_sha256", value: json_string(s: digest)), json_kv(key: "credential_version", value: json_string(s: version)), + json_kv(key: "filament_feed", value: json_string(s: "ams-slot-1")), json_kv(key: "attempt", value: json_string(s: attempt)), ])) match sha256_hex_digest(hex: sha256_hex(message: bytes_octets(b: utf8_encode_bytes(s: subject)))) { @@ -49,7 +50,7 @@ fn print_request(printer: PhysicalAssetIdentity, digest: String, version: String subject: subject, purpose: join(["Print one Altra mounting gauge on ", printer as String, ". Project SHA-256 ", digest, "; credential version ", version, "; batch ", attempt, - ". Bed reported clear. Uses the operator's one-hour token; no IAM or credential changes. One start only."], "") as NonEmptyStr, + ". AMS slot 1. Bed reported clear. Uses the operator's one-hour token; no IAM or credential changes. One start only."], "") as NonEmptyStr, attempt: attempt as AttemptIdentity, intent_hash: as_content_hash_cryptographic(digest: hash), destructive: false, diff --git a/dag/test/claim/printer_job_start_witness_test.dag b/dag/test/claim/printer_job_start_witness_test.dag index fcf2f35eb2a..bae0ac8583a 100644 --- a/dag/test/claim/printer_job_start_witness_test.dag +++ b/dag/test/claim/printer_job_start_witness_test.dag @@ -159,13 +159,12 @@ test fn the_start_payload_names_the_delivered_file() -> Bool { && string_contains(s: json, pattern: "\"command\": \"project_file\"") } -// use_ams IS FALSE, and it is checked rather than trusted to the constructor. The operator's -// constraint is one filament per printer -- printer-01 white, printer-02 black -- and a true here -// would make the machine wait on an AMS that is not bound to this print. -test fn the_start_payload_does_not_ask_for_an_ams() -> Bool { +// Physical slot 1 is wire tray 0, not tray 1 or the external spool. +test fn the_start_payload_selects_ams_slot_one() -> Bool { let json = bambu_start_payload_json( command: bambu_start_command(remote_name: a_remote_name), sequence_id: "1") - string_contains(s: json, pattern: "\"use_ams\": false") + string_contains(s: json, pattern: "\"use_ams\": true") && + string_contains(s: json, pattern: "\"ams_mapping\": [0]") } // THE BROKER IS TLS, MEASURED ON THE MACHINE RATHER THAN ASSUMED FROM THE PORT NUMBER. diff --git a/dag/test/claim/printer_operator_approval_witness_test.dag b/dag/test/claim/printer_operator_approval_witness_test.dag index 82b0cc5b081..fc455e05090 100644 --- a/dag/test/claim/printer_operator_approval_witness_test.dag +++ b/dag/test/claim/printer_operator_approval_witness_test.dag @@ -1,5 +1,7 @@ module test.claim.printer_operator_approval_witness_test +import test.claim.printer_job_start_witness_test { the_start_payload_selects_ams_slot_one } + import extdeps.shell.exec { ProcessObservation } import extdeps.tools.mosquitto_pub { mosquitto_pub_help_observation_admitted } @@ -47,3 +49,8 @@ test fn help_exit_one_needs_the_actual_supported_version_banner() -> Bool { test fn approval_submission_follows_the_store_writer() -> Bool { submit_and_status_urls_follow_the_writer_cutover() } + + +test fn operator_batch_uses_confirmed_ams_slot() -> Bool { + the_start_payload_selects_ams_slot_one() +} diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 03b1e55c31b..3931919fac8 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -201,8 +201,8 @@ The shared approval client now follows the declared writer cutover to the approv approval polling. Four operator-route witnesses passed, including all three writer postures. Fresh read-only reports showed printer-01 IDLE/error 0 and printer-02 IDLE/error 83902511. Both reported PLA in AMS tray 0 (physical slot 1), while the existing start command selects the -external spool. Execution is held before upload pending confirmation of filament feed and the -printer-02 screen error. No start marker, physical start, or RUNNING observation is claimed. +external spool. The initial executions were stopped before upload to resolve that mismatch. +No start marker, physical start, or RUNNING observation was claimed at that point. ### Local preparation environment @@ -1050,3 +1050,16 @@ node pitch (which decides upright versus flat-on-riser, and the riser makes it a aux power connector type and sustained draw against the node supply and stack distribution · whether a mixed stack is allowed at all, since a uniform GPU stack and a mixed stack have different sidewall answers. + +### Batch follow-up: AMS and printer-02 storage + +On 2026-10-03 the operator confirmed AMS physical slot 1 on both printers and identified +printer-02 error 83902511 (0x0500402f) as a microSD error. The operator was unsure whether +that card was in use. This LAN route uploads to SD storage and starts a `file:///sdcard/` +project, so the error remains a start blocker; it is not suppressed. No formatting or deletion +of printer storage is authorized or performed. The start payload now selects AMS and maps +the single project filament to tray 0; the signed operator request includes this feed choice. + +The operator subsequently reported formatting printer-02’s card locally. A fresh no-error +report is still required before its start. Remote formatting was requested as a future capability; +the current modeled workflow has no format operation, and support on these printers is unverified. From 65d6bef29a0eb0380f21b4caa1c44f647c03f22f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:07:52 +0000 Subject: [PATCH 06/24] Wait for complete printer telemetry before start admission --- dag/gunbc/fleet/printer_report.dag | 37 ++++++++++++++++++- ...printer_operator_approval_witness_test.dag | 8 ++++ docs/plans/printed-chassis-program.md | 6 +++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/dag/gunbc/fleet/printer_report.dag b/dag/gunbc/fleet/printer_report.dag index 758c1e5904b..4ced9265db9 100644 --- a/dag/gunbc/fleet/printer_report.dag +++ b/dag/gunbc/fleet/printer_report.dag @@ -1,6 +1,8 @@ module gunbc.fleet.printer_report -import std.types { String, NonEmptyStr, Secret } +import std.types { String, NonEmptyStr, Secret, Int, Bool } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, json_object_unique_member, JsonMemberFound } +import extdeps.languages.json.emit { JsonString, JsonNumber } import std.process { ProcessExit, ExitSuccess, exit_failure } import extdeps.shell import extdeps.shell.exec { ProcessOutcome, ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } @@ -35,7 +37,7 @@ fn report(printer: PhysicalAssetIdentity, version: NonEmptyStr, token_source: Ac PrinterSerialFound { serial: serial } => match printer_endpoint_address(printer: printer) { PrinterAddressFound { address: address } => match printer_access_code_locus(printer: printer) { PrinterAccessCodeDesiredLocusFound { locus: locus } => match fetch_secret_ref_credential(secret_ref: secret_ref_pin_version(ref: locus.secret, version: version), token_source: token_source) { - SecretCredentialReady { credential: c, resolved_version: _ } => report_with_credential(serial: serial, address: address, credential: c) + SecretCredentialReady { credential: c, resolved_version: _ } => complete_report_with_credential(serial: serial, address: address, credential: c, remaining: 8) _ => ProcessRefused { exit_code: 1, stderr: "Cannot read the requested printer credential" } } _ => ProcessRefused { exit_code: 1, stderr: "Printer credential locus is absent or ambiguous" } @@ -89,3 +91,34 @@ fn observe_both(version: String, directory: String) -> ProcessExit { other => other } } + +// Bambu sends partial telemetry deltas. Only a single fresh message containing both +// state and error is complete enough for start admission; never infer missing fields. +fn report_has_state_and_error(text: String) -> Bool { + match parse_json_document(s: text) { + JsonDocumentParsed { value: root } => match json_object_unique_member(v: root, key: "print") { + JsonMemberFound { value: p } => { + let state = match json_object_unique_member(v: p, key: "gcode_state") { + JsonMemberFound { value: JsonString { value: _ } } => true + _ => false + } + let error = match json_object_unique_member(v: p, key: "print_error") { + JsonMemberFound { value: JsonNumber { lexeme: _ } } => true + _ => false + } + state && error + } + _ => false + } + _ => false + } +} + +fn complete_report_with_credential(serial: NonEmptyStr, address: NonEmptyStr, credential: Secret, remaining: Int) -> ProcessOutcome { + if remaining <= 0 { return ProcessRefused { exit_code: 1, stderr: "No complete state/error report within eight fresh observations" } } + match report_with_credential(serial: serial, address: address, credential: credential) { + ProcessOutputPresent { text: text } => if report_has_state_and_error(text: text) { ProcessOutputPresent { text: text } } + else { complete_report_with_credential(serial: serial, address: address, credential: credential, remaining: remaining - 1) } + other => other + } +} diff --git a/dag/test/claim/printer_operator_approval_witness_test.dag b/dag/test/claim/printer_operator_approval_witness_test.dag index fc455e05090..65033ed5a73 100644 --- a/dag/test/claim/printer_operator_approval_witness_test.dag +++ b/dag/test/claim/printer_operator_approval_witness_test.dag @@ -2,6 +2,8 @@ module test.claim.printer_operator_approval_witness_test import test.claim.printer_job_start_witness_test { the_start_payload_selects_ams_slot_one } +import gunbc.fleet.printer_report { report_has_state_and_error } + import extdeps.shell.exec { ProcessObservation } import extdeps.tools.mosquitto_pub { mosquitto_pub_help_observation_admitted } @@ -54,3 +56,9 @@ test fn approval_submission_follows_the_store_writer() -> Bool { test fn operator_batch_uses_confirmed_ams_slot() -> Bool { the_start_payload_selects_ams_slot_one() } + +test fn partial_telemetry_does_not_count_as_a_complete_observation() -> Bool { + !report_has_state_and_error(text: "{\"print\":{\"nozzle_temper\":25}}") && + !report_has_state_and_error(text: "{\"print\":{\"gcode_state\":\"IDLE\"}}") && + report_has_state_and_error(text: "{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":83902511}}") +} diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 3931919fac8..5618e3567a4 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1063,3 +1063,9 @@ the single project filament to tray 0; the signed operator request includes this The operator subsequently reported formatting printer-02’s card locally. A fresh no-error report is still required before its start. Remote formatting was requested as a future capability; the current modeled workflow has no format operation, and support on these printers is unverified. + +At 22:01 UTC printer-02's AMS request was approved. Its fresh report admitted IDLE/error 0, +but FTPS upload returned curl exit 25 / server 550 before MQTT start. Its durable batch marker +is retained; there is no automatic replay. Printer-01's approved attempt received a partial +temperature-only report and correctly refused before upload. The observer now allows up to +eight fresh messages to obtain state and error together, without inventing missing fields. From 9c2c0128cbea61e90df9662e78757f13f3ee9668 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:15:23 +0000 Subject: [PATCH 07/24] Request full printer status before admitting gauge starts --- dag/extdeps/exec/command.dag | 1 + dag/extdeps/exec/program.dag | 1 + .../printing/bambu_lab_lan_protocol.dag | 9 +++++ dag/extdeps/tools/mosquitto_rr.dag | 33 +++++++++++++++++++ dag/gunbc/auth/mosquitto_options_binding.dag | 15 +++++++++ dag/gunbc/fleet/printer_report.dag | 14 ++++---- docs/plans/printed-chassis-program.md | 10 ++++++ 7 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 dag/extdeps/tools/mosquitto_rr.dag diff --git a/dag/extdeps/exec/command.dag b/dag/extdeps/exec/command.dag index 9b2be81a6a9..5502ab06c9a 100644 --- a/dag/extdeps/exec/command.dag +++ b/dag/extdeps/exec/command.dag @@ -175,6 +175,7 @@ fn argv_command(program: ProgramIdentity, arguments: List) -> ArgvComman decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_publish_command"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_pub_help_command"), decl_ref(module_path: "extdeps.tools.mosquitto_sub", decl_name: "mosquitto_report_command"), + decl_ref(module_path: "extdeps.tools.mosquitto_rr", decl_name: "mosquitto_snapshot_command"), decl_ref(module_path: "extdeps.tools.gnu_time", decl_name: "gnu_time_max_rss_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_version_command"), decl_ref(module_path: "extdeps.systemd.systemctl", decl_name: "systemctl_user_manager_probe_command"), diff --git a/dag/extdeps/exec/program.dag b/dag/extdeps/exec/program.dag index f96e741054d..5977ed179b5 100644 --- a/dag/extdeps/exec/program.dag +++ b/dag/extdeps/exec/program.dag @@ -63,6 +63,7 @@ fn cataloged_program(invocation: NonEmptyStr) -> ProgramIdentity decl_ref(module_path: "extdeps.tools.conntrack", decl_name: "conntrack_program"), decl_ref(module_path: "extdeps.tools.mosquitto_pub", decl_name: "mosquitto_pub_program"), decl_ref(module_path: "extdeps.tools.mosquitto_sub", decl_name: "mosquitto_sub_program"), + decl_ref(module_path: "extdeps.tools.mosquitto_rr", decl_name: "mosquitto_rr_program"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_httpd_command"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_tftpd_over_udpsvd_command"), decl_ref(module_path: "extdeps.tools.busybox", decl_name: "busybox_dd_sparse_image_command"), diff --git a/dag/extdeps/printing/bambu_lab_lan_protocol.dag b/dag/extdeps/printing/bambu_lab_lan_protocol.dag index 481e3b7789a..869e2227be3 100644 --- a/dag/extdeps/printing/bambu_lab_lan_protocol.dag +++ b/dag/extdeps/printing/bambu_lab_lan_protocol.dag @@ -204,3 +204,12 @@ data bambu_printer_ca_bundle_source: ExternalAuthority = ExternalAuthority { data bambu_printer_ca_bundle_sha256: NonEmptyStr = "36f2bcee347ec7adce719b5fd350099591a4d3d0ec4e039c7019890d78e152a0" data bambu_ca_measured_fingerprint_sha256: NonEmptyStr = "030BCA81CECE18B7EFF3CFD2B75D09D3EFCA893BC069609E37FA04257FE4D840" + +fn bambu_status_snapshot_payload() -> String { + serialize_json(v: json_object(members: [json_kv(key: "pushing", value: json_object(members: [ + json_kv(key: "command", value: json_string(s: "pushall")), + json_kv(key: "sequence_id", value: json_string(s: "0")), + json_kv(key: "version", value: json_int(n: 1)), + json_kv(key: "push_target", value: json_int(n: 1)), + ]))])) +} diff --git a/dag/extdeps/tools/mosquitto_rr.dag b/dag/extdeps/tools/mosquitto_rr.dag new file mode 100644 index 00000000000..51af6d6f5b0 --- /dev/null +++ b/dag/extdeps/tools/mosquitto_rr.dag @@ -0,0 +1,33 @@ +module extdeps.tools.mosquitto_rr + +import std.types { NonEmptyStr, String, FilePath } +import extdeps.exec.command { ArgvCommand, argv_command } +import extdeps.exec.program { ProgramIdentity, cataloged_program } +import extdeps.tools { CliTool, SourceApt } +import extdeps.apt { package_mosquitto_clients, apt_bin_dir } +import extdeps.tools.mosquitto_pub { mosquitto_pub_pinned_version } +import extdeps.oasis.mqtt { MqttEndpoint, MqttTopic } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "mosquitto.org/man/mosquitto_rr-1.html" } +} +fn mosquitto_rr_program() -> ProgramIdentity = cataloged_program(invocation: "mosquitto_rr") + +data mosquitto_rr_cli_tool: CliTool = CliTool { + program: mosquitto_rr_program(), + min_version: Present { value: mosquitto_pub_pinned_version }, + installable_via: [SourceApt { package: package_mosquitto_clients, bin_dir: apt_bin_dir }], +} + +data mosquitto_rr_options_file_name: NonEmptyStr = "mosquitto_rr" + +// Subscribe before requesting read-only status, using MQTT 3.1.1 and a bounded wait. +fn mosquitto_snapshot_command(endpoint: MqttEndpoint, topic: MqttTopic, request_topic: MqttTopic, payload: String, ca_bundle: FilePath) -> ArgvCommand { + argv_command(program: mosquitto_rr_program(), arguments: [ + "-h", endpoint.host as String, "-p", to_string(endpoint.port), + "--cafile", ca_bundle as String, "--insecure", "-e", topic as String, + "-t", request_topic as String, "-m", payload, "-V", "mqttv311", "-q", "1", "-R", "-W", "15", + ]) +} diff --git a/dag/gunbc/auth/mosquitto_options_binding.dag b/dag/gunbc/auth/mosquitto_options_binding.dag index bb820dce942..1c48de52d9d 100644 --- a/dag/gunbc/auth/mosquitto_options_binding.dag +++ b/dag/gunbc/auth/mosquitto_options_binding.dag @@ -1,5 +1,7 @@ module gunbc.auth.mosquitto_options_binding +import extdeps.tools.mosquitto_rr { mosquitto_rr_options_file_name } + import extdeps.tools.mosquitto_sub { mosquitto_sub_options_file_name } import std.types { NonEmptyStr, String, Bool, Secret, FilePath, List } @@ -140,3 +142,16 @@ fn place_mosquitto_sub_options_privately(login: NonEmptyStr, password: Secret) - other => other } } + +fn place_mosquitto_rr_options_privately(login: NonEmptyStr, password: Secret) -> MosquittoOptionsPlacement { + match place_mosquitto_options_privately(login: login, password: password) { + MosquittoOptionsPlaced { binding: b } => { + let source = join([b.dir as String, "/", mosquitto_pub_options_file_name as String], "") + let target = join([b.dir as String, "/", mosquitto_rr_options_file_name as String], "") + if shell.Copy.File(source: source as FilePath, destination: target as FilePath).success { + MosquittoOptionsPlaced { binding: b } + } else { mosquitto_options_write_refusal(removed_after_failure: shred_mosquitto_options_binding(binding: b)) } + } + other => other + } +} diff --git a/dag/gunbc/fleet/printer_report.dag b/dag/gunbc/fleet/printer_report.dag index 4ced9265db9..956c0b90bf3 100644 --- a/dag/gunbc/fleet/printer_report.dag +++ b/dag/gunbc/fleet/printer_report.dag @@ -8,8 +8,8 @@ import extdeps.shell import extdeps.shell.exec { ProcessOutcome, ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } import extdeps.filesystem.filesystem_io { Filesystem } import extdeps.exec.command { env_prefixed_command, LocalExec } -import extdeps.tools.mosquitto_sub { mosquitto_report_command } -import extdeps.printing.bambu_lab_lan_protocol { bambu_report_topic, bambu_lan_mqtt_endpoint, bambu_lan_mqtt_user } +import extdeps.tools.mosquitto_rr { mosquitto_snapshot_command } +import extdeps.printing.bambu_lab_lan_protocol { bambu_status_snapshot_payload, bambu_request_topic, bambu_report_topic, bambu_lan_mqtt_endpoint, bambu_lan_mqtt_user } import gunbc.command_runner { run_shell_command_capture } import gunbc.fleet_asset_identity { PhysicalAssetIdentity, printer_01, printer_02 } import gunbc.fleet.printer_workflow_run { select_printer, PrinterSelected, PrinterSelectionRefused } @@ -24,12 +24,12 @@ import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } import gunbc.auth.access_token_source { AccessTokenSource, OperatorSuppliedToken, read_supplied_access_token, SuppliedTokenReady, SuppliedTokenUnavailable } import gunbc.auth.secret_ref_credential { fetch_secret_ref_credential, SecretCredentialReady } import gunbc.auth.mosquitto_options_binding { - place_mosquitto_sub_options_privately, MosquittoOptionsPlaced, + place_mosquitto_rr_options_privately, MosquittoOptionsPlaced, MosquittoOptionsRefused, MosquittoOptionsRefusedResidueRemains, shred_mosquitto_options_binding, mosquitto_options_env, } -// Read-only subscriber. Topic/endpoint/credential all join through the same fleet identity. +// Read-only status request/response. Topic/endpoint/credential all join through the same fleet identity. // It observes current state, not causality or an acknowledgement of a particular start. fn report(printer: PhysicalAssetIdentity, version: NonEmptyStr, token_source: AccessTokenSource) -> ProcessOutcome { if !sm_version_id_is_number(version: version as String) { return ProcessRefused { exit_code: 1, stderr: "Report requires an exact credential version" } } @@ -53,12 +53,12 @@ fn report_with_credential(serial: NonEmptyStr, address: NonEmptyStr, credential: CaBundleRefused { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } CaBundleRefusedResidueRemains { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } CaBundlePinned { bundle: bundle, workspace: workspace } => { - let result = match place_mosquitto_sub_options_privately(login: bambu_lan_mqtt_user, password: credential) { + let result = match place_mosquitto_rr_options_privately(login: bambu_lan_mqtt_user, password: credential) { MosquittoOptionsRefused { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } MosquittoOptionsRefusedResidueRemains { cause: c } => ProcessRefused { exit_code: 1, stderr: c as String } MosquittoOptionsPlaced { binding: b } => { - let outcome = run_shell_command_capture(command: env_prefixed_command(bindings: mosquitto_options_env(binding: b), command: mosquitto_report_command( - endpoint: bambu_lan_mqtt_endpoint(host: address), topic: bambu_report_topic(serial: serial), ca_bundle: pinned_ca_bundle_path(bundle: bundle))), transport: LocalExec) + let outcome = run_shell_command_capture(command: env_prefixed_command(bindings: mosquitto_options_env(binding: b), command: mosquitto_snapshot_command( + endpoint: bambu_lan_mqtt_endpoint(host: address), topic: bambu_report_topic(serial: serial), request_topic: bambu_request_topic(serial: serial), payload: bambu_status_snapshot_payload(), ca_bundle: pinned_ca_bundle_path(bundle: bundle))), transport: LocalExec) if shred_mosquitto_options_binding(binding: b) { outcome } else { ProcessRefused { exit_code: 1, stderr: "Printer report credential cleanup failed" } } } diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 5618e3567a4..51df7d11e04 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1069,3 +1069,13 @@ but FTPS upload returned curl exit 25 / server 550 before MQTT start. Its durabl is retained; there is no automatic replay. Printer-01's approved attempt received a partial temperature-only report and correctly refused before upload. The observer now allows up to eight fresh messages to obtain state and error together, without inventing missing fields. + +The operator confirmed that printer-02 recovered and appeared functional after formatting. +A separate after-format attempt retains the original failed upload marker and requests new +approval. No MQTT start had been sent in the failed attempt. Status observation now subscribes +before publishing a read-only `pushing.pushall` snapshot request with Mosquitto request/response; +it still requires an explicit complete state/error report and cleans the private credential files. + +Correction at 22:12 UTC: the operator reports printer-02 still shows SD card “error” in +settings despite clearing print_error. Stop its after-format attempt before actuation and +prioritize printer-01. A zero print_error is not proof of usable storage. From f48bdc6aed80e95bca66417a8f58caedef4d8f52 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:19:52 +0000 Subject: [PATCH 08/24] Record confirmed printer one gauge execution and printer two storage fault --- docs/plans/printed-chassis-program.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 51df7d11e04..ed584f68a9a 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1079,3 +1079,20 @@ it still requires an explicit complete state/error report and cleans the private Correction at 22:12 UTC: the operator reports printer-02 still shows SD card “error” in settings despite clearing print_error. Stop its after-format attempt before actuation and prioritize printer-01. A zero print_error is not proof of usable storage. + +### Printer-01 start observed by operator + +At approximately 22:15 UTC the full-snapshot gate admitted printer-01 and its holes 3–4 +project uploaded successfully. MQTT publication did not return PUBACK. The operator then +confirmed the machine was heating or calibrating. The publisher had already exited with +connection-lost code 7 and cleaned its credential/CA directories before termination was attempted. +The transport outcome is UNKNOWN; the batch marker is retained and no start replay is permitted. +This is operator-observed preparation, not yet a telemetry-confirmed extrusion or completed print. +The selected feed is AMS physical slot 1; slice estimate 19m22s. Printer-02 remains held for +SD-card settings error, despite its zero print_error report. + +At 22:19 UTC a fresh authenticated snapshot confirmed printer-01 `RUNNING`, `print_error=0`, +and subtask name `gunbc-28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636af701580e.3mf`. +It reported 4%, 18 minutes remaining, layer 0/15 (preparation), nozzle 176 °C and bed 62 °C. +This reconciles the unknown MQTT transport result with observed execution of the intended gauge; +it does not establish completed extrusion or a finished part. No duplicate start was sent. From ec1a45859a3b08d1365cb40c5824abbaf8eb9888 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:45:59 +0000 Subject: [PATCH 09/24] Record gauge fit results and prepare remaining print candidates --- docs/plans/printed-chassis-program.md | 43 ++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index ed584f68a9a..175ffc42683 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -88,7 +88,8 @@ no measured installed envelope. **Recovered operator setup reports:** Creality PLA, white on printer-01 and black on printer-02, reported September 4–5; on October 3 the operator says both setups are identical. These reports are retained in `gunbc.fleet.printer_setup_reports`, with source timestamps and a read-only `main` -projection. The AMS slot-1 report does not name a printer, so it is not assigned to both machines. +projection. The historical AMS report did not name a printer. The operator explicitly confirmed AMS physical +slot 1 on both machines on October 3; that now governs the print command. Nozzle/plate settings still need reconciliation against retained execution evidence. Recovered files disagree: `print-n/work/gauge_petg.3mf` specifies PETG with textured PEI; `printn-slice/gauge_container.3mf` specifies PLA with a cool plate. Preset metadata does not supersede the operator’s filament reports. Do not reuse their machine G-code blindly. Final slicing must also check placement including brim/skirt: the longest narrow gauge nearly spans the 180 mm bed. Keep model @@ -1096,3 +1097,43 @@ and subtask name `gunbc-28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636a It reported 4%, 18 minutes remaining, layer 0/15 (preparation), nozzle 176 °C and bed 62 °C. This reconciles the unknown MQTT transport result with observed execution of the intended gauge; it does not establish completed extrusion or a finished part. No duplicate start was sent. + +### Printer-02 recovery retry + +The operator subsequently reported printer-02's card working and requested another attempt, +supplying a fresh one-hour token. The previously stopped after-format attempt had no start claim +and had sent no printer command. At approximately 22:31 UTC its new signed approval was observed, +the fresh full-state check admitted the printer, and the holes 4–5 file uploaded successfully. +AMS physical slot 1 remains selected. MQTT publication is awaiting reconciliation; it is not +replayed. The earlier FTP-550 attempt and its claim remain separate and retained. + +At approximately 22:36 UTC, fresh telemetry confirmed printer-02 RUNNING/error 0 with the +expected `gunbc-a8f434e80014b6a316d30665b372bd0df3e858a68a2ff04a5fe4f50031d68535.3mf` +subtask, 38%, layer 0/15, and 8 minutes remaining. The upload/start route now has physical +execution evidence after the card recovery; this is not a long-term card-health qualification. + +The operator reported printer-01 finished the holes 3–4 gauge, both holes line up well, +and the part was removed / bed cleared. Record C–H as an operator-reported alignment pass, +without inferring load-bearing qualification. Prepare holes 3–6 (C–B) as printer-01's next gauge. + +The operator authorized the next printer-01 print after confirming C–H fit and a clear bed. +Holes 3–6 (C–B) sliced through the verified Orca entry with the same selected profiles. +Readback: SHA-256 `fb6d4bd1fcb23c07087b5575e6f5b20619f7ab8da03217828148d84cfcefc09f`, +15 layers / 3 mm, identity scaling, one plate, valid embedded G-code MD5 and ZIP integrity, +brim bounds [57.211684, 80.071684, 122.788316, 99.928316] mm, 12m10s / 2.59 g PLA. +The slicer metadata carries `bed_temperature_too_high_than_filament` at the upstream Generic PLA +textured-plate setting of 65 °C; this setting matches the successfully printed/aligned prior gauge. +It is retained as an observed warning, not described as warning-free. The new request binds this +project digest and a separate holes-3-6 attempt; printer-02's running job is not touched. + +The other remaining candidates are also sliced and validated under +`/home/briansrls/print-prep-2026-10-03/next-batch/` (no start requested for either yet): + +| Gauge | SHA-256 | Estimate | PLA | +|---|---|---|---| +| 5–2 (L–M) | `607601f18602ae273472654c01b9d34ca1674b7f6f5356c7136a382af41f545b` | 19m34s | 7.10 g | +| 4–7 (H–R) | `972ec6278c3334e070d4bd31884ce781211454c1c32296652e5f9a6ae4269252` | 13m26s | 3.42 g | + +Both passed ZIP/G-code checksum, one-plate, 15-layer/3-mm, identity-transform, A1 mini/PLA/0.4-mm +profile, and 180-mm bed-bound checks. L–M including brim spans X=1.331684–178.668316 mm; no +scaling was applied. Both retain the same 65 °C bed-temperature warning described above. From 97b036643136b7b5aa5084d8ce0beb541afb82c3 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:49:05 +0000 Subject: [PATCH 10/24] Stop further gauge queue and record chassis prototype direction --- docs/plans/printed-chassis-program.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 175ffc42683..5eef035512e 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1137,3 +1137,20 @@ The other remaining candidates are also sliced and validated under Both passed ZIP/G-code checksum, one-plate, 15-layer/3-mm, identity-transform, A1 mini/PLA/0.4-mm profile, and 180-mm bed-bound checks. L–M including brim spans X=1.331684–178.668316 mm; no scaling was applied. Both retain the same 65 °C bed-temperature warning described above. + +### Operator direction: stop the pair-gauge sequence + +The operator requested stopping further gauge printing and moving toward chassis/rack parts. +Do not enqueue the prepared 5–2 or 4–7 gauges. A fresh snapshot already confirmed the newly +started printer-01 3–6 job RUNNING/error 0 with its exact digest filename and 12 minutes remaining; +clarification is pending on whether to cancel that active print or let it finish. +The operator expects the remaining positions to be accurate; retain that as an expectation, not +a measured fit result. 1–3 and 3–4 have reported alignment passes. + +Repository inspection found no printable full chassis/cassette/rack-bay geometry. Existing concrete +geometry covers coupons, standoff gauges and a fan-mount plate; rack_mount models occupancy of +catalog hardware, not printable rack construction. The proposed next geometry is a segmented, +adjustable motherboard carrier for unpowered assembly/fit, forming the first chassis prototype. +Remaining mounting positions can stay adjustable rather than requiring more pair gauges. Actual +standoff thread/height and underside clearance still determine the board-support interface; +PSU/cooler envelopes, structural material and load/joint checks remain for the powered cassette. From c3fc919f882ace85b8ec7838b8b6c097bfaf2b56 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 22:49:54 +0000 Subject: [PATCH 11/24] Record decision to finish the active gauge only --- docs/plans/printed-chassis-program.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 5eef035512e..0d98d505a3d 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1143,7 +1143,7 @@ scaling was applied. Both retain the same 65 °C bed-temperature warning describ The operator requested stopping further gauge printing and moving toward chassis/rack parts. Do not enqueue the prepared 5–2 or 4–7 gauges. A fresh snapshot already confirmed the newly started printer-01 3–6 job RUNNING/error 0 with its exact digest filename and 12 minutes remaining; -clarification is pending on whether to cancel that active print or let it finish. +the operator explicitly chose to let that active print finish. No cancellation is requested. The operator expects the remaining positions to be accurate; retain that as an expectation, not a measured fit result. 1–3 and 3–4 have reported alignment passes. From 2d912276920b99d0f8049f65499e1c2911975ea6 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 3 Oct 2026 23:09:12 +0000 Subject: [PATCH 12/24] Record hosted interactive chassis concept workbench --- docs/plans/printed-chassis-program.md | 13 +++++++++++++ sites/.gitignore | 2 ++ sites/README.md | 9 +++++++++ 3 files changed, 24 insertions(+) create mode 100644 sites/.gitignore create mode 100644 sites/README.md diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 0d98d505a3d..5c2e9f23a62 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1154,3 +1154,16 @@ adjustable motherboard carrier for unpowered assembly/fit, forming the first cha Remaining mounting positions can stay adjustable rather than requiring more pair gauges. Actual standoff thread/height and underside clearance still determine the board-support interface; PSU/cooler envelopes, structural material and load/joint checks remain for the powered cassette. + +### Hosted concept workbench + +The operator accepted the requirements and requested a hosted site for iterating on the chassis, +including independent 3D rotation of each piece. Published privately: +[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site). +It contains an explicitly provisional four-panel carrier, segmented rails, handle and retaining +tabs, with a 2×2 fixed-frame study. Individual isolation/orbit, explosion, withdrawal and design +controls are available. There are no print controls or manufacturing exports. +The missing hardware dimensions remain unknown; the geometry is a visual proposal, not an +admitted fabrication result. Source project details are recorded in `sites/README.md`. +Desktop/mobile rendering and the principal interactions were exercised successfully. +All temporary copies of the operator's GCP token were removed after printing work ended. diff --git a/sites/.gitignore b/sites/.gitignore new file mode 100644 index 00000000000..bb544ad8a7d --- /dev/null +++ b/sites/.gitignore @@ -0,0 +1,2 @@ +# This hosted Site has its own versioned source repository managed by Sites. +/chassis-workbench/ diff --git a/sites/README.md b/sites/README.md new file mode 100644 index 00000000000..cde5f0c6a41 --- /dev/null +++ b/sites/README.md @@ -0,0 +1,9 @@ +# Chassis concept site + +[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site) is the private interactive design surface for iterating on the Altra carrier and rack. Site project: `appgprj_6ac187f2fe208191b1ec7ad6703d93e8`. + +The separate Site checkout is `sites/chassis-workbench/` in the printing worktree. Its source is committed to the Sites-managed repository, first published commit `44ea76b0914cf99f6e80e94fee26554544b72cfe`. Reopen that same project for edits rather than creating another site. + +The page provides 11 selectable pieces per cassette, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. + +This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Actual standoff, underside, cooler, PSU and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. From e1bccf932baadba0c524434c8ae3cdc5727568e9 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 00:10:57 +0000 Subject: [PATCH 13/24] Record interchangeable PSU and cassette airflow concept --- docs/plans/printed-chassis-program.md | 32 +++++++++++++++++++++++++++ sites/README.md | 4 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 5c2e9f23a62..e40c9bcfc4b 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1167,3 +1167,35 @@ The missing hardware dimensions remain unknown; the geometry is a visual proposa admitted fabrication result. Source project details are recorded in `sites/README.md`. Desktop/mobile rendering and the principal interactions were exercised successfully. All temporary copies of the operator's GCP token were removed after printing work ended. + +### Cassette hardware and airflow study — 4 October 2026 + +Operator clarification: the target cooler is Dynatron (W1 in the preceding question), and PSUs +must be interchangeable rather than tied to one model. Roughly 400–600 W was an example range, +not a finalized electrical qualification. PSU compatibility must include accepted physical envelope, +mounting adapters, connector requirements, intake/exhaust clearance and service access; wattage +alone is insufficient. The inventory's unread PSU identities remain unread. + +Concept 02 adds a rear cassette-resident adjustable metal PSU cradle, a variable PSU envelope, +Dynatron W1 geometry, DIMM clearance zones, one to three front 80 mm fans with individual carrier +feet, side airflow guides, a schematic internal harness corridor and front-to-rear direction arrows. +The operator proposed cassette-mounted fans. All these components travel with the cassette in the +2×2 frame view. Three fans is a layout default, not an established cooling requirement. + +The [Dynatron manufacturer page](https://www.dynatron.co/product-page/w1), checked 4 October, +publishes active dimensions 141.1 × 84 × 72 mm and passive dimensions 116 × 84 × 72 mm. +The study depicts the active envelope with simplified fins/fan; active versus passive operation, +actual socket position/orientation and installed offset are not established by that depiction. +The P8 PWM PST uses its catalog 80 × 80 × 25 mm envelope; the existing 71.5 mm hole pitch and +4.4 mm clearance remain convention-unverified. Fan count, ducting and header/PST-chain electrical +admission still require evidence. Airflow arrows are not simulation results. + +PSU slider defaults (150 × 85 × 150 mm), DIMM zones, the 50 mm rear service gap, adjustable cradle +interfaces and harness route are design proposals. They are not measured hardware or a claim that +all 400–600 W PSUs fit. No drive or expansion-card configuration has been invented. A height check +flags simple study-pitch conflicts; it does not prove full assembly clearance or powered suitability. + +Source commit: `8c944389c0dbcb25639d4c6069587863ae0d0fd5` in the separate Sites repository. +Browser checks passed for selectable parts, PSU isolation, fan-count changes, height-conflict +feedback, airflow visibility, orbit, rack withdrawal, requirements and mobile overflow. +No additional gauge or chassis print was started. diff --git a/sites/README.md b/sites/README.md index cde5f0c6a41..743e5efc31f 100644 --- a/sites/README.md +++ b/sites/README.md @@ -4,6 +4,6 @@ The separate Site checkout is `sites/chassis-workbench/` in the printing worktree. Its source is committed to the Sites-managed repository, first published commit `44ea76b0914cf99f6e80e94fee26554544b72cfe`. Reopen that same project for edits rather than creating another site. -The page provides 11 selectable pieces per cassette, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. +Concept 02 source: `8c944389c0dbcb25639d4c6069587863ae0d0fd5`. The page provides 26 selectable parts/reference objects per cassette at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. -This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Actual standoff, underside, cooler, PSU and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. +This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Dynatron W1 is the target cooler and its published active envelope is depicted; installed offset/orientation remain unresolved. PSU compatibility is model-independent with adjustable envelope and cradle proposals, targeting roughly 400–600 W provisionally. Actual standoff, underside, accepted PSU interfaces and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. From 378706af29bf29f7eebf01d3b44be5f9dacf0cde Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 00:21:10 +0000 Subject: [PATCH 14/24] Adopt shared fixed-frame power per four-node chassis block --- docs/plans/printed-chassis-program.md | 33 ++++++++++++++++++++++++--- sites/README.md | 4 ++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index e40c9bcfc4b..891ac6bad3b 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -288,8 +288,8 @@ whether more printers are worth buying. | Fan form factor | 80 mm; **count derived**, not assumed | operator + derivation | | Rack unit | 2x2 block, composable | side-chat ruling | | Service law | fixed frame, removable cassette | operator requirement R9 | -| PSU placement | cassette-resident | side-chat ruling | -| External bundle | 2 x Ethernet + 1 x AC per node | operator | +| PSU placement | shared 12 V supply system on fixed frame, one per four-node block | operator acceptance, 4 October 2026; supersedes cassette-resident PSU | +| External bundle | 2 x Ethernet + 1 x protected, isolatable DC feed per node; AC at shared supply | operator acceptance, 4 October 2026 | ## Standing laws @@ -321,7 +321,7 @@ because a step without one cannot be said to be done. | **PRINT-9** | CAL | Coupons printed on BOTH printers; each printer+spool admitted or refused **independently** | coupon feedback recorded; independent qualification and repeatability pending | | **PRINT-10** | FIT | Adjustable-standoff fixture; real board mounted unpowered; hole map measured back and frozen | gauge in hand; 1–3 (F–C) alignment pass reported; full fixture/map open | | **PRINT-11** | CASSETTE | Structural cassette: rails, tray, handle, latch; carries node mass; no seam in layer-separation tension | needs PETG decision | -| **PRINT-12** | CASSETTE | PSU carrier and harnesses; every connector insertable; nothing side-loaded; **PE continuous with board, standoffs and cassette all removed** | needs PSU envelope | +| **PRINT-12** | CASSETTE | Shared fixed-frame PSU/distribution and node harnesses; every connector insertable; nothing side-loaded; **PE continuous with board, standoffs and cassette all removed** | needs PSU envelope | | **PRINT-13** | CASSETTE | 80 mm fan carrier + replaceable duct; powered thermal admitted against a bench baseline | needs cooler choice | | **PRINT-14** | RACK | One fixed bay; cassette retained in service, removable after disconnect; empty bay structurally complete | | | **PRINT-15** | RACK | Management-node mount (Raspberry-Pi class) on the rack, carried as a bay peer rather than an accessory | | @@ -1199,3 +1199,30 @@ Source commit: `8c944389c0dbcb25639d4c6069587863ae0d0fd5` in the separate Sites Browser checks passed for selectable parts, PSU isolation, fan-count changes, height-conflict feedback, airflow visibility, orbit, rack withdrawal, requirements and mobile overflow. No additional gauge or chassis print was started. + +### Shared power accepted — supersedes cassette-resident PSU + +On 4 October 2026 the operator accepted shared power per four-node block. This replaces the earlier +cassette-resident PSU requirement and its per-node AC bundle. Eight nodes comprise two independently +powered four-node blocks. Each block has a fixed-frame 12 V supply system and four separately +protected, isolatable and disconnectable node feeds. Cassettes retain their board, cooler and fans. +The earlier roughly 400–600 W example concerned individual supplies and is not a shared-block rating. + +[ASRock manual §2.8](https://download.asrock.com/Manual/ALTRAD8UD-1L2T.pdf) documents direct +12 V +DC input and distinguishes it from the ATX signal-adapter connection. This establishes a supported +input mode, not a qualified multi-node distribution system. Required power connections, independent +startup/shutdown behaviour, aggregate load/startup demand, branch fault protection, wire/connector +ratings, voltage drop, protective-earth continuity and service isolation still require engineering. +No connector pinout, fuse size, PSU wattage, redundancy or live hot-swap capability is selected here. +A nonredundant shared supply is a four-node failure domain; redundancy remains an open decision. + +Concept 03 depicts one fixed top power shelf, one adjustable shared PSU space reservation, one +four-branch distribution placeholder and stationary branch harnesses. A DC disconnect placeholder +travels with each cassette. Withdrawal illustrates the node shut down and its branch isolated and +disconnected first. Top-shelf placement and all distribution dimensions are visual proposals. Shared +supply height is additional to the two node rows; cassette pitch is governed by cassette hardware. +Older Concept 02 notes above are retained as history and are superseded for power topology. + +Concept 03 source: `7f77b6884f23af034fd1632e5918c3a21e988e5a` in the Sites repository. +Browser checks passed: shared PSU isolation, no PSU on the cassette-only view, fan-count controls, +height feedback, airflow visibility, rack withdrawal, requirements and mobile layout. diff --git a/sites/README.md b/sites/README.md index 743e5efc31f..4e1b154a8c6 100644 --- a/sites/README.md +++ b/sites/README.md @@ -4,6 +4,6 @@ The separate Site checkout is `sites/chassis-workbench/` in the printing worktree. Its source is committed to the Sites-managed repository, first published commit `44ea76b0914cf99f6e80e94fee26554544b72cfe`. Reopen that same project for edits rather than creating another site. -Concept 02 source: `8c944389c0dbcb25639d4c6069587863ae0d0fd5`. The page provides 26 selectable parts/reference objects per cassette at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. +Concept 03 source: `7f77b6884f23af034fd1632e5918c3a21e988e5a`. The page provides 29 selectable parts/reference objects in rack view (25 on a cassette) at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. -This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Dynatron W1 is the target cooler and its published active envelope is depicted; installed offset/orientation remain unresolved. PSU compatibility is model-independent with adjustable envelope and cradle proposals, targeting roughly 400–600 W provisionally. Actual standoff, underside, accepted PSU interfaces and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. +This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Dynatron W1 is the target cooler and its published active envelope is depicted; installed offset/orientation remain unresolved. Concept 03 uses one fixed-frame shared 12 V supply system per four-node block, with four protected/isolatable node feeds. PSU capacity and distribution hardware remain unselected; the former per-node 400–600 W example is superseded. Actual standoff, underside, accepted PSU interfaces and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. From bcffcf4564813783762a6ab5b7340a1bf176fed2 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 00:52:35 +0000 Subject: [PATCH 15/24] Prepare rear exhaust fan-mount prototype through modeled CAD and slicing --- dag/gunbc/fleet/printer_operator_approval.dag | 2 +- .../product/printed_chassis/fan_mount.dag | 4 +-- .../printed_chassis/fan_mount_print.dag | 31 ++++++++++++++++++ .../product/printed_chassis/slice_run.dag | 8 ++--- docs/plans/printed-chassis-program.md | 32 +++++++++++++++++++ sites/README.md | 4 ++- 6 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 dag/gunbc/product/printed_chassis/fan_mount_print.dag diff --git a/dag/gunbc/fleet/printer_operator_approval.dag b/dag/gunbc/fleet/printer_operator_approval.dag index e121f62b0ac..8b617038a2c 100644 --- a/dag/gunbc/fleet/printer_operator_approval.dag +++ b/dag/gunbc/fleet/printer_operator_approval.dag @@ -48,7 +48,7 @@ fn print_request(printer: PhysicalAssetIdentity, digest: String, version: String action: "gunbc.printer.project-file.start" as AuthorizedAction, }], subject: subject, - purpose: join(["Print one Altra mounting gauge on ", printer as String, ". Project SHA-256 ", digest, + purpose: join(["Print one prepared part on ", printer as String, ". Project SHA-256 ", digest, "; credential version ", version, "; batch ", attempt, ". AMS slot 1. Bed reported clear. Uses the operator's one-hour token; no IAM or credential changes. One start only."], "") as NonEmptyStr, attempt: attempt as AttemptIdentity, diff --git a/dag/gunbc/product/printed_chassis/fan_mount.dag b/dag/gunbc/product/printed_chassis/fan_mount.dag index 0eb4c4db37a..f3d8831638e 100644 --- a/dag/gunbc/product/printed_chassis/fan_mount.dag +++ b/dag/gunbc/product/printed_chassis/fan_mount.dag @@ -122,8 +122,8 @@ fn admit_fan_mount( } // THE FIRST PART TO PRINT. A 92 mm plate carries the 71.5 mm pattern with material to spare, 4 mm of -// thickness is stiff enough to hold a fan without warping on a textured plate, and a 76 mm bore clears the -// blades while staying well inside the 50.6 mm screw-hole circle. +// thickness is an unqualified prototype choice, and the 76 mm aperture stays inside the +// screw-hole circle. Held-fan fit, stiffness, cassette attachment and guarding remain to be checked. data arctic_p8_mount_plate_edge: Micrometer = micrometer(count: 92000) data arctic_p8_mount_plate_thickness: Micrometer = micrometer(count: 4000) data arctic_p8_mount_bore: Micrometer = micrometer(count: 76000) diff --git a/dag/gunbc/product/printed_chassis/fan_mount_print.dag b/dag/gunbc/product/printed_chassis/fan_mount_print.dag new file mode 100644 index 00000000000..730a94b791c --- /dev/null +++ b/dag/gunbc/product/printed_chassis/fan_mount_print.dag @@ -0,0 +1,31 @@ +module product.printed_chassis.fan_mount_print + +import std.types { String } +import std.process { ExitSuccess, exit_failure } +import gunbc.cli_wire { CliWireResponse, CliWirePrintable } +import product.printed_chassis.fan_mount { + arctic_p8_fan_mount, FanMountAdmitted, +} +import product.printed_chassis.cadquery_realization { + plate_part_from_fan_mount, realize_plate_part, CadQueryEmitted, cadquery_program_source, +} +import product.printed_chassis.export_profile { exercised_stl_knobs } +import extdeps.cadquery.cadquery { stl_export_call } + +// First rear fan carrier part: the existing admitted 92 x 92 x 4 mm plate. +// Geometry and hole positions remain owned by fan_mount; no duplicate coordinates here. +// This emits an unpowered assembly-fit prototype, not qualified structural/powered hardware. +// Cassette attachment and a finger guard are separate, unresolved interfaces. +fn main() -> CliWireResponse { + match arctic_p8_fan_mount { + FanMountAdmitted { geometry: g } => match realize_plate_part(p: plate_part_from_fan_mount(g: g)) { + CadQueryEmitted { program: p } => CliWirePrintable { + bytes: concat(cadquery_program_source(p: p) as String, + stl_export_call(destination: "rear-fan-mount.stl", knobs: exercised_stl_knobs)), + exit: ExitSuccess, + }, + _ => CliWirePrintable { bytes: "", exit: exit_failure(reason: "Fan mount CAD emission refused") }, + }, + _ => CliWirePrintable { bytes: "", exit: exit_failure(reason: "Fan mount geometry refused") }, + } +} diff --git a/dag/gunbc/product/printed_chassis/slice_run.dag b/dag/gunbc/product/printed_chassis/slice_run.dag index d0d38fd98e2..068a1b681a2 100644 --- a/dag/gunbc/product/printed_chassis/slice_run.dag +++ b/dag/gunbc/product/printed_chassis/slice_run.dag @@ -12,11 +12,11 @@ import extdeps.languages.json.emit { JsonKeyValue, JsonObject, json_object, json // Selected slicing settings, not a claim of live printer telemetry. The 2 mm brim // keeps the 170.94 mm gauge inside the 180 mm envelope without scaling the gauge. -fn gauge_settings_members() -> List { +fn fit_prototype_settings_members() -> List { [ json_kv(key: "type", value: json_string(s: "process")), json_kv(key: "from", value: json_string(s: "user")), - json_kv(key: "name", value: json_string(s: "Altra gauge 0.20 mm PLA")), + json_kv(key: "name", value: json_string(s: "Unpowered fit prototype 0.20 mm PLA")), json_kv(key: "curr_bed_type", value: json_string(s: "Textured PEI Plate")), json_kv(key: "brim_type", value: json_string(s: "outer_only")), json_kv(key: "brim_width", value: json_string(s: "2")), @@ -31,14 +31,14 @@ fn run(appimage: String, model: String, output: String, machine: String, filamen let settings = concat(output, ".settings.json") let original = Filesystem.Read(path: process) if !original.success { return exit_failure(reason: "Cannot read base process profile") } - let overrides = gauge_settings_members() + let overrides = fit_prototype_settings_members() let content = match parse_json_document(s: original.content) { JsonDocumentParsed { value: JsonObject { members: members } } => serialize_json(v: json_object(members: concat(filter(members, m => !any(overrides, o => o.key == m.key)), overrides))) _ => "" } if content == "" || !Filesystem.Write(path: settings, content: content).success { - return exit_failure(reason: "Cannot parse or write gauge process profile") + return exit_failure(reason: "Cannot parse or write fit-prototype process profile") } match with_verified_orca(appimage: appimage as FilePath, use: executable => { let probe = run_shell_command_capture(command: orca_version_probe_command(executable: executable), transport: LocalExec) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 891ac6bad3b..09aa9f99b72 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1226,3 +1226,35 @@ Older Concept 02 notes above are retained as history and are superseded for powe Concept 03 source: `7f77b6884f23af034fd1632e5918c3a21e988e5a` in the Sites repository. Browser checks passed: shared PSU isolation, no PSU on the cassette-only view, fan-count controls, height feedback, airflow visibility, rack withdrawal, requirements and mobile layout. + +### Rear exhaust and first fan-mount print — 4 October 2026 + +The operator requested moving fans to the rear to keep hands at the front handle away from them, +and authorized modeling/printing a first part. Rear cassette-mounted exhaust is the new layout; +front-to-rear airflow remains. Rear I/O/cable access and a finger guard remain unresolved. Both +beds were reported clear; printer-01 is selected with the previously confirmed PLA in AMS slot 1. + +The first real part reuses `fan_mount.arctic_p8_fan_mount`: 92 × 92 × 4 mm plate, 76 mm aperture, +71.5 mm hole pitch and 4.4 mm mounting holes. This is a useful fan mounting plate for unpowered +assembly fit, not another motherboard spacing gauge and not a complete cassette. Cassette +attachment and guarding have not been fabricated. Hole-pattern evidence remains convention-unverified. +`fan_mount_print.main` carries the existing admitted geometry through the existing CadQuery emitter +and exercised STL export settings; there are no independently authored CAD coordinates. + +Generation and verification artifacts are in `/home/briansrls/print-prep-2026-10-04/rear-fan-mount`. +CadQuery 2.8.0 checks passed for one valid solid, dimensions, all five hole axes/radii, analytic volume, +STEP round-trip and a closed manifold STL. STL SHA-256: +`2b0c7df7e8cf79dc772a33ee6f62a6ccf9e5568ef07e0309545313480e35b4f8`. +Orca 2.4.2 sliced through `slice_run.run` at 0.20 mm, 20 layers, PLA/0.4 mm nozzle, 220 °C nozzle, +65 °C textured bed and 2 mm outer brim. Estimate 28m41s, 11.40 g. ZIP/G-code checksum, one plate, +identity scaling and 180 mm bed bounds passed; bounds including brim are 42.071684–137.928316 mm +on both axes. The retained bed-temperature warning matches the previously exercised 65 °C setting. +The slice_info first-layer-time metadata is malformed; G-code estimates 6m4s and that metadata is +not an admission input. Project SHA-256: +`71da943ff2ed73a6b47c54759ba288e82af7d084c269bf9a413d4d901cf5199c`. + +Concept 04 is published with rear fans and downloadable generated STL/STEP, source +`c2a49273da70f20a23e2e044fe552dceb74b773f`. Browser interaction checks passed. +The operator-token/ntfy route is being used because PR #13149 remains unmerged. The request binds +printer-01, credential version 1, exact project digest and attempt `rear-fan-mount-20261004-01`. +A start result must be observed separately; preparation is not evidence of printing. diff --git a/sites/README.md b/sites/README.md index 4e1b154a8c6..b417f2d5c6f 100644 --- a/sites/README.md +++ b/sites/README.md @@ -4,6 +4,8 @@ The separate Site checkout is `sites/chassis-workbench/` in the printing worktree. Its source is committed to the Sites-managed repository, first published commit `44ea76b0914cf99f6e80e94fee26554544b72cfe`. Reopen that same project for edits rather than creating another site. -Concept 03 source: `7f77b6884f23af034fd1632e5918c3a21e988e5a`. The page provides 29 selectable parts/reference objects in rack view (25 on a cassette) at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. +Concept 04 source: `c2a49273da70f20a23e2e044fe552dceb74b773f`. The page provides 29 selectable parts/reference objects in rack view (25 on a cassette) at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Dynatron W1 is the target cooler and its published active envelope is depicted; installed offset/orientation remain unresolved. Concept 03 uses one fixed-frame shared 12 V supply system per four-node block, with four protected/isolatable node feeds. PSU capacity and distribution hardware remain unselected; the former per-node 400–600 W example is superseded. Actual standoff, underside, accepted PSU interfaces and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. + +Concept 04 moves fans to rear exhaust and provides the DAG-generated 92 × 92 × 4 mm fan plate as STL/STEP downloads. The plate is an unpowered fit prototype; cassette attachment and a finger guard remain unresolved. From 6b0677747ac58e52ec9a1aa5e51c8a2395f94564 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 01:09:09 +0000 Subject: [PATCH 16/24] Record approved rear fan-plate start and live RUNNING observation --- docs/plans/printed-chassis-program.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 09aa9f99b72..778f0039248 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1258,3 +1258,25 @@ Concept 04 is published with rear fans and downloadable generated STL/STEP, sour The operator-token/ntfy route is being used because PR #13149 remains unmerged. The request binds printer-01, credential version 1, exact project digest and attempt `rear-fan-mount-20261004-01`. A start result must be observed separately; preparation is not evidence of printing. + +The first launch refused before submission because its submission-key environment variable was +missing; it filed no approval and created no start marker. Corrected operator launches on srv1 +require these existing key paths (never their contents) plus the private token-file path: + +- `GUNBC_APPROVAL_SUBMISSION_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-submission-mac-key` +- `GUNBC_APPROVAL_CAPABILITY_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-mac-key` +- `GUNBC_APPROVAL_STORE_RECEIPT_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-store-receipt-mac-key` +- `RUNNER_TEMP` pointing to an owner-only staging directory. + +The refusal log is retained as `print-01-preflight-refusal.log` under the srv1 run directory. +Retrying the same attempt before any request/start marker exists does not replay a printer start. + +The corrected run passed the signed approval gate, observed the printer ready, claimed its durable +start marker, uploaded the exact project and sent one AMS-slot-1 start. MQTT publication lost its +connection without acknowledgement after two minutes, so the effect was classified UNKNOWN and +was not replayed. A separate modeled `printer_report.run` observation then returned RUNNING, +print_error 0, exact digest filename `gunbc-71da943ff2ed73a6b47c54759ba288e82af7d084c269bf9a413d4d901cf5199c.3mf`, +19% and 23 minutes remaining. This confirms the requested job active; layer_num was 0, so it is +not yet evidence of deposited layers or completed fit. The snapshot is retained locally and on srv1. +The operator independently reported probably heating. Printer-02 was not started. Temporary copies +of the operator token were deleted from both hosts. From c1f6d42b14d31bc6e30e88f5faf087e007a708fa Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 03:25:48 +0000 Subject: [PATCH 17/24] Model complete cassette assembly and publish generated CAD review --- dag/extdeps/cooling/dynatron.dag | 7 +- dag/extdeps/fans/arctic.dag | 5 +- .../printed_chassis/assembly_geometry.dag | 46 ++++ .../product/printed_chassis/cassette.dag | 197 ++++++++++++++++++ .../printed_chassis/cassette_review.dag | 47 +++++ .../product/printed_chassis/rack_assembly.dag | 45 ++++ .../printed_chassis/review_realization.dag | 195 +++++++++++++++++ docs/plans/printed-chassis-program.md | 71 +++++++ sites/README.md | 37 +++- 9 files changed, 642 insertions(+), 8 deletions(-) create mode 100644 dag/gunbc/product/printed_chassis/assembly_geometry.dag create mode 100644 dag/gunbc/product/printed_chassis/cassette.dag create mode 100644 dag/gunbc/product/printed_chassis/cassette_review.dag create mode 100644 dag/gunbc/product/printed_chassis/rack_assembly.dag create mode 100644 dag/gunbc/product/printed_chassis/review_realization.dag diff --git a/dag/extdeps/cooling/dynatron.dag b/dag/extdeps/cooling/dynatron.dag index 35df435c133..d4865bf3c2d 100644 --- a/dag/extdeps/cooling/dynatron.dag +++ b/dag/extdeps/cooling/dynatron.dag @@ -1,7 +1,7 @@ module extdeps.cooling.dynatron import std.types { NonEmptyStr, List } -import std.measure { watt, rack_unit } +import std.measure { Micrometer, micrometer, watt, rack_unit } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.cooling.types { CpuCoolerCatalogRow } import extdeps.cpu.types { @@ -40,3 +40,8 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { first_citation: extdeps_external_authority_anchor, further_citations: [] } + +// Manufacturer active W1 envelope; installation pose belongs to the product assembly. +data dynatron_w1_active_length: Micrometer = micrometer(count: 141100) +data dynatron_w1_active_width: Micrometer = micrometer(count: 84000) +data dynatron_w1_active_height: Micrometer = micrometer(count: 72000) diff --git a/dag/extdeps/fans/arctic.dag b/dag/extdeps/fans/arctic.dag index d028bc5fc18..f29a9a4403b 100644 --- a/dag/extdeps/fans/arctic.dag +++ b/dag/extdeps/fans/arctic.dag @@ -1,7 +1,7 @@ module extdeps.fans.arctic import std.types { List } -import std.measure { micrometer, millimeter, percent, rpm } +import std.measure { Micrometer, micrometer, millimeter, percent, rpm } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.fans.types { FanMountingPattern, @@ -106,3 +106,6 @@ data arctic_p8_mounting_pattern: FanMountingPattern = fan_mounting_pattern( convention: "80 mm axial fan square mounting pattern, unverified against a held part", }, ) + +// Published axial frame depth, separate from the conventional mounting-hole pattern. +data arctic_p8_frame_depth: Micrometer = micrometer(count: 25000) diff --git a/dag/gunbc/product/printed_chassis/assembly_geometry.dag b/dag/gunbc/product/printed_chassis/assembly_geometry.dag new file mode 100644 index 00000000000..d094e9f258e --- /dev/null +++ b/dag/gunbc/product/printed_chassis/assembly_geometry.dag @@ -0,0 +1,46 @@ +module product.printed_chassis.assembly_geometry + +import std.types { Int, String, List, Bool } +import std.measure { Micrometer, micrometer } +import std.affine_space { Point3, point3 } +import std.coordinate_frame { coordinate_frame_identity } +import std.euclidean_geometry { Axis3 } +import product.printed_chassis.coupon { PlateDimensions, plate_dimensions } + +// Review geometry for the first assembled cassette. It is NOT AdmittedRealizationV0 and +// cannot enter the coupon's fabrication admission through a cast. The generic CAD adapter +// must validate positive primitives, solid connectivity, output extents and A1-mini fit. +// This explicit review rung replaces the former Three.js-only geometric authority. +// Coordinates use existing affine points; quantum is one micrometre, X across rear I/O, +// Y up, Z from rear toward the handle. Part-local frame 5100; assembly frame 5101. +type Primitive = Block { size: PlateDimensions, center: Point3 } + | RoundPrism { diameter: Micrometer, length: Micrometer, axis: Axis3, center: Point3 } +type PartKind = Printed | Purchased | Metal | Clearance +// Orientation of the part's assembly-local coordinates when laid on a print bed. +type PrintPlane = LayOnXZ | LayOnXY | LayOnYZ +// Scope prevents rack power equipment being cloned into each moving cassette. +type PartScope = Cassette | FixedFrame + +type AssemblyPart { + id: String, name: String, kind: PartKind, scope: PartScope, + adds: List, cuts: List, at: Point3, explode: Point3, + print_plane: PrintPlane, notes: String, +} +type MechanicalInterface { + id: String, members: List, fastener: String, quantity: Int, + status: String, requirement: String, +} +fn local(x: Int, y: Int, z: Int) -> Point3 { point3(frame: coordinate_frame_identity(occurrence: 5100), x: x, y: y, z: z) } +fn placed(x: Int, y: Int, z: Int) -> Point3 { point3(frame: coordinate_frame_identity(occurrence: 5101), x: x, y: y, z: z) } +fn block(w: Int, h: Int, d: Int, x: Int, y: Int, z: Int) -> Primitive { + Block { size: plate_dimensions(size_x: micrometer(count: w), size_y: micrometer(count: h), size_z: micrometer(count: d)), center: local(x: x,y: y,z: z) } +} +fn round(diameter: Int, length: Int, axis: Axis3, x: Int, y: Int, z: Int) -> Primitive { + RoundPrism { diameter: micrometer(count: diameter), length: micrometer(count: length), axis: axis, center: local(x: x,y: y,z: z) } +} +fn part(id: String, name: String, kind: PartKind, scope: PartScope, adds: List, cuts: List, x: Int,y: Int,z: Int, ex: Int,ey: Int,ez: Int, plane: PrintPlane, notes: String) -> AssemblyPart { + AssemblyPart { id: id,name: name,kind: kind,scope: scope,adds: adds,cuts: cuts,at: placed(x: x,y: y,z: z),explode: placed(x: ex,y: ey,z: ez),print_plane: plane,notes: notes } +} +fn mechanical_interface(id: String,members: List,fastener: String,quantity: Int,status: String,requirement: String) -> MechanicalInterface { + MechanicalInterface { id: id,members: members,fastener: fastener,quantity: quantity,status: status,requirement: requirement } +} diff --git a/dag/gunbc/product/printed_chassis/cassette.dag b/dag/gunbc/product/printed_chassis/cassette.dag new file mode 100644 index 00000000000..44be37c6031 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/cassette.dag @@ -0,0 +1,197 @@ +module product.printed_chassis.cassette + +import std.types { Bool, Int, String, List } +import std.measure { micrometer_count } +import std.euclidean_geometry { Axis3X, Axis3Y, Axis3Z } +import product.printed_chassis.assembly_geometry { + Primitive, AssemblyPart, MechanicalInterface, Printed, Purchased, Metal, Clearance, + Cassette, FixedFrame, LayOnXZ, LayOnXY, LayOnYZ, block, round, part, mechanical_interface, +} +import extdeps.boards.asrock_rack { asrock_altrad8ud_extent_along_rear_io, asrock_altrad8ud_extent_depth } +import product.printed_chassis.fan_mount { + arctic_p8_fan_mount, FanMountAdmitted, fan_mount_plate, fan_mount_bore, fan_mount_mounting_holes, +} +import product.printed_chassis.coupon { OpThroughHole } +import extdeps.fans.arctic { arctic_p8_mounting_pattern } + +// This root owns the entire review assembly formerly authored in the browser. +// Nominal board lengths come from the board authority; design allowances below are proposals. +// No measured board thickness, standoff thread, PSU rating or thermal result is invented. +data board_w: Int = micrometer_count(m: asrock_altrad8ud_extent_along_rear_io) +data board_d: Int = micrometer_count(m: asrock_altrad8ud_extent_depth) +data margin: Int = 14000 +data tray_w: Int = board_w + 2 * margin +data tray_d: Int = board_d + 2 * margin +data panel_t: Int = 5000 +data seam: Int = 2000 +data panel_w: Int = (tray_w - seam) / 2 +data panel_d: Int = (tray_d - seam) / 2 +data bay_pitch: Int = 140000 +data bay_w: Int = tray_w + 48000 +data rear_plate_z: Int = 0 - tray_d / 2 - 76000 +data rear_beam_z: Int = rear_plate_z + 20000 +data fan_spacing: Int = 94000 +data fan_hole_offset: Int = micrometer_count(m: arctic_p8_mounting_pattern.hole_pitch) / 2 +data fan_hole_d: Int = micrometer_count(m: arctic_p8_mounting_pattern.hole_clearance_diameter) +data m3_clearance: Int = 3400 +// Fan fit reported by operator 2026-10-04: physical plate/fan fit passed. Does not +// rewrite the upstream ConventionUnverified row into a dimensional measurement. +data fan_fit_observation: String = "Operator reported the printed rear fan plate fits the fan, 2026-10-04. No additional gauge required." +fn bolt_y(x: Int,z: Int) -> Primitive { round(diameter: m3_clearance,length: 40000,axis: Axis3Y,x: x,y: 0,z: z) } + +fn panel(sx: Int,sz: Int,id: String) -> AssemblyPart { + let cx = sx * (tray_w + seam) / 4 + let cz = sz * (tray_d + seam) / 4 + let slot_w = (panel_w - 52000) / 4 + let vents = fold([0,1,2,3],init: [],f: fn(acc,i) { + let x = 0 - panel_w / 2 + 26000 + slot_w / 2 + i * slot_w + let width = slot_w - 4000 + let half_run = (panel_d - 56000 - width) / 2 + concat(acc,[block(w: width,h: 9000,d: half_run*2,x: x,y: 2500,z: 0), + round(diameter: width,length: 9000,axis: Axis3Y,x: x,y: 2500,z: half_run), + round(diameter: width,length: 9000,axis: Axis3Y,x: x,y: 2500,z: 0-half_run)]) + }) + // Seam holes sit in the perimeter rim. Bridges below share these exact global centres. + let seam_x = fold([-5000,5000],init: [],f: fn(acc,dz) { concat(acc,[bolt_y(x: sx*12000-cx,z: dz)]) }) + let seam_z = fold([-5000,5000],init: [],f: fn(acc,dx) { concat(acc,[bolt_y(x: dx,z: sz*12000-cz)]) }) + let rail = fold([-1,1],init: [],f: fn(acc,j) { concat(acc,[bolt_y(x: sx*(tray_w/2-7000)-cx,z: j*(panel_d/2-18000))]) }) + let handle = if sz==1 { [bolt_y(x: sx*50000-cx,z: tray_d/2-10000-cz)] } else { [] } + let extension = if sz== -1 { [bolt_y(x: sx*(tray_w/2-7000)-cx,z: 0-tray_d/2+20000-cz)] } else { [] } + let retainer = if sz==1 { [bolt_y(x: sx*(tray_w/2-22000)-cx,z: tray_d/2-18000-cz)] } else { [] } + part(id: id,name: concat("Carrier panel ",id),kind: Printed,scope: Cassette, + adds: [block(w: panel_w,h: panel_t,d: panel_d,x: 0,y: panel_t/2,z: 0)], + cuts: concat(vents,concat(seam_x,concat(seam_z,concat(rail,concat(handle,concat(extension,retainer)))))), + x: cx,y: 0,z: cz,ex: sx*65000,ey: 12000,ez: sz*55000,plane: LayOnXZ, + notes: "Four vented panels; M3 through-bolted seams and rail interfaces. Board supports remain a separate unresolved interface; do not place a powered board directly on this tray.") +} +fn panel_bridge(sx: Int,sz: Int,id: String) -> AssemblyPart { + let across_x = sx==0 + let w = if across_x { 42000 } else { 20000 } + let d = if across_x { 20000 } else { 42000 } + let holes = fold([-1,1],init: [],f: fn(acc,a) { concat(acc,fold([-1,1],init: [],f: fn(inner,b) { concat(inner,[bolt_y(x: a*(if across_x {12000} else {5000}),z: b*(if across_x {5000} else {12000}))]) })) }) + part(id: id,name: "Panel seam splice",kind: Printed,scope: Cassette,adds:[block(w:w,h:5000,d:d,x:0,y:0,z:0)],cuts:holes, + x:sx*(tray_w+seam)/4,y: -2500,z:sz*(tray_d+seam)/4,ex:sx*40000,ey: -45000,ez:sz*40000,plane:LayOnXZ,notes:"Four M3 through-bolts; joint geometry proposal, unpowered assembly fit first.") +} +fn rail(sx: Int,sz: Int,id: String) -> AssemblyPart { + let cx=sx*(tray_w/2-2000) + let cz=sz*(tray_d+seam)/4 + let holes=concat(fold([-1,1],init:[],f:fn(acc,j){concat(acc,[bolt_y(x:0-sx*5000,z:j*(panel_d/2-18000))])}), + [bolt_y(x:0-sx*5000,z:sz*12000-cz)]) + let cuts=if sz== -1 {concat(holes,[bolt_y(x:0-sx*5000,z:0-tray_d/2+20000-cz)])} else {holes} + part(id:id,name:"Segmented carrier rail",kind:Printed,scope:Cassette, + adds:[block(w:24000,h:4000,d:panel_d,x:0,y: -2000,z:0),block(w:4000,h:16000,d:panel_d,x:sx*10000,y:4000,z:0)],cuts:cuts, + x:cx,y:0,z:cz,ex:sx*85000,ey: -20000,ez:sz*30000,plane:LayOnXZ,notes:"L section; overlaps panel edge by 14 mm. M3 bolts to panel and separate rail splice. Sliding clearance/load qualification remains open.") +} +fn rail_bridge(sx: Int,id: String) -> AssemblyPart { + part(id:id,name:"Rail seam splice",kind:Printed,scope:Cassette,adds:[block(w:18000,h:4000,d:42000,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z: -12000),bolt_y(x:0,z:12000)], + x:sx*(tray_w/2-7000),y: -6000,z:0,ex:sx*90000,ey: -45000,ez:0,plane:LayOnXZ,notes:"Two M3 through-bolts across the rail seam; backing nuts/washers remain accessible below.") +} +fn handle() -> AssemblyPart { + part(id:"H1",name:"Front pull handle",kind:Printed,scope:Cassette, + adds:[block(w:110000,h:10000,d:12000,x:0,y:0,z:30000),block(w:18000,h:10000,d:48000,x: -46000,y:0,z:12000),block(w:18000,h:10000,d:48000,x:46000,y:0,z:12000)], + cuts:[bolt_y(x: -50000,z:0),bolt_y(x:50000,z:0)],x:0,y:10000,z:tray_d/2-10000,ex:0,ey:15000,ez:110000,plane:LayOnXZ,notes:"M3 through-bolts into both front panels. Pull/load test before carrying a populated node; front kept clear of chassis fans.") +} +fn retaining_tab(sx: Int,id: String) -> AssemblyPart { + part(id:id,name:"Front retaining stop",kind:Printed,scope:Cassette, + adds:[block(w:24000,h:5000,d:36000,x:0,y:0,z:0),block(w:6000,h:12000,d:9000,x:sx*9000,y:6000,z:13000)],cuts:[bolt_y(x:0,z: -8000)], + x:sx*(tray_w/2-22000),y:7500,z:tray_d/2-10000,ex:sx*50000,ey:40000,ez:55000,plane:LayOnXZ,notes:"Rigid stop with pivot bolt, not a snap latch. Engagement with the metal rack still needs review.") +} +fn extension(sx: Int,id: String) -> AssemblyPart { + part(id:id,name:"Rear fan-bank extension",kind:Printed,scope:Cassette, + adds:[block(w:20000,h:8000,d:112000,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z:30000),bolt_y(x:0,z: -46000)], + x:sx*(tray_w/2-7000),y: -8000,z:0-tray_d/2-10000,ex:sx*55000,ey: -45000,ez: -50000,plane:LayOnXZ, + notes:"Connects the rear panel/rail stack to the fan crossmember. Cantilever stiffness and creep unqualified.") +} +fn fan_beam(sx: Int,id: String) -> AssemblyPart { + let width=2*fan_spacing+92000 + let cx=sx*(width+seam)/4 + let w=(width-seam)/2 + let feet=fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,fold([-1,1],init:[],f:fn(inner,j){ + let x=i*fan_spacing+j*25000 + if x*sx>0 {concat(inner,[bolt_y(x:x-cx,z:0)])} else {inner} + }))}) + part(id:id,name:"Rear fan crossmember half",kind:Printed,scope:Cassette, + adds:[block(w:w,h:8000,d:24000,x:0,y:0,z:0)],cuts:concat(feet,[bolt_y(x:sx*12000-cx,z:0),bolt_y(x:sx*(tray_w/2-7000)-cx,z:0)]), + x:cx,y:0,z:rear_beam_z,ex:sx*50000,ey: -10000,ez: -85000,plane:LayOnXZ,notes:"Three fan feet bolt to this split crossmember. End holes align with rear extension arms; centre splice bridges the print split.") +} +fn fan_beam_bridge() -> AssemblyPart { + part(id:"FBJ",name:"Fan crossmember splice",kind:Printed,scope:Cassette,adds:[block(w:42000,h:4000,d:20000,x:0,y:0,z:0)],cuts:[bolt_y(x: -12000,z:0),bolt_y(x:12000,z:0)], + x:0,y: -6000,z:rear_beam_z,ex:0,ey: -50000,ez: -85000,plane:LayOnXZ,notes:"Two M3 bolts join the rear crossmember halves.") +} +fn fan_plate(i: Int,id: String) -> AssemblyPart { + // Read the sealed geometry used by the successfully fitted print. No copied pattern. + let adds=match arctic_p8_fan_mount { FanMountAdmitted {geometry:g} => { + let p=fan_mount_plate(g:g) + [block(w:micrometer_count(m:p.size_x),h:micrometer_count(m:p.size_y),d:micrometer_count(m:p.size_z),x:0,y:0,z:0)] + } _ => [] } + let cuts=match arctic_p8_fan_mount { FanMountAdmitted {geometry:g} => { + let p=fan_mount_plate(g:g) + concat([fan_mount_bore(g:g)],fan_mount_mounting_holes(g:g)) |> map(op => match op { + OpThroughHole {diameter:diam,center_x:x,center_y:y} => round(diameter:micrometer_count(m:diam),length:10000,axis:Axis3Z, + x:micrometer_count(m:x)-micrometer_count(m:p.size_x)/2,y:micrometer_count(m:y)-micrometer_count(m:p.size_y)/2,z:0) + }) + } _ => [] } + part(id:id,name:"Rear fan mounting plate",kind:Printed,scope:Cassette,adds:adds,cuts:cuts, + x:i*fan_spacing,y:55000,z:rear_plate_z,ex:i*40000,ey:0,ez: -60000,plane:LayOnXY,notes:fan_fit_observation) +} +fn fan_foot(i: Int,id: String) -> AssemblyPart { + part(id:id,name:"Rear fan plate foot",kind:Printed,scope:Cassette, + adds:[block(w:92000,h:5000,d:35000,x:0,y:6500,z:10500),block(w:92000,h:25000,d:5000,x:0,y:16500,z: -4500)], + cuts:concat(if i==0 {[]} else {[bolt_y(x:i*(tray_w/2-7000-fan_spacing),z:20000)]},[bolt_y(x: -25000,z:20000),bolt_y(x:25000,z:20000),round(diameter:fan_hole_d,length:12000,axis:Axis3Z,x: -fan_hole_offset,y:55000-fan_hole_offset,z: -4500),round(diameter:fan_hole_d,length:12000,axis:Axis3Z,x:fan_hole_offset,y:55000-fan_hole_offset,z: -4500)]), + x:i*fan_spacing,y:0,z:rear_plate_z,ex:i*40000,ey: -20000,ez: -60000,plane:LayOnXZ,notes:"L bracket: two lower fan bolts and two M3 crossmember bolts. Printed flat on its base; vertical wall has no unsupported overhang.") +} +fn fan_guard(i: Int,id: String) -> AssemblyPart { + let rim=[block(w:92000,h:8000,d:3000,x:0,y:42000,z:0),block(w:92000,h:8000,d:3000,x:0,y: -42000,z:0),block(w:8000,h:92000,d:3000,x:42000,y:0,z:0),block(w:8000,h:92000,d:3000,x: -42000,y:0,z:0)] + let pads=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,y){concat(inner,[block(w:12000,h:12000,d:3000,x:x*fan_hole_offset,y:y*fan_hole_offset,z:0)])}))}) + let bars=fold([-3,-2,-1,0,1,2,3],init:[],f:fn(acc,j){concat(acc,[block(w:84000,h:4000,d:3000,x:0,y:j*11000,z:0)])}) + let holes=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,y){concat(inner,[round(diameter:fan_hole_d,length:8000,axis:Axis3Z,x:x*fan_hole_offset,y:y*fan_hole_offset,z:0)])}))}) + part(id:id,name:"Rear fan guard",kind:Printed,scope:Cassette,adds:concat(rim,concat(pads,bars)),cuts:holes, + x:i*fan_spacing,y:55000,z:rear_plate_z-10500,ex:i*40000,ey:0,ez: -105000,plane:LayOnXY,notes:"7 mm nominal grille gaps, provisional guard geometry; not a certified finger-protection rating. Upper 7 mm and lower 2 mm spacers clear the 5 mm foot behind the plate.") +} +fn guard_spacer(i:Int,sx:Int,sy:Int,id:String) -> AssemblyPart { + let len=if sy==1 {7000} else {2000} + part(id:id,name:"Fan guard spacer",kind:Printed,scope:Cassette, + adds:[round(diameter:8000,length:len,axis:Axis3Z,x:0,y:0,z:0)],cuts:[round(diameter:fan_hole_d,length:len+2000,axis:Axis3Z,x:0,y:0,z:0)], + x:i*fan_spacing+sx*fan_hole_offset,y:55000+sy*fan_hole_offset,z:rear_plate_z-9000+len/2,ex:i*40000+sx*10000,ey:sy*12000,ez: -85000,plane:LayOnXY, + notes:"Spacing proposal for the guard/plate/foot fastener stack. Verify clearance and fastener engagement in complete assembly.") +} +fn guide(sx:Int,id:String) -> AssemblyPart { + let gx=fan_spacing+46000+3000 + let reach=gx-(tray_w/2-7000) + part(id:id,name:"Rear side airflow guide",kind:Printed,scope:Cassette, + adds:[block(w:3000,h:80000,d:120000,x:0,y:40000,z:0),block(w:reach+10000,h:4000,d:120000,x:0-sx*reach/2,y:2000,z:0)], + cuts:[bolt_y(x:0-sx*reach,z:30000),bolt_y(x:0-sx*reach,z: -46000)], + x:sx*gx,y:13000,z:0-tray_d/2-10000,ex:sx*75000,ey:20000,ez: -35000,plane:LayOnYZ, + notes:"L-section guide shares the extension stack. Front bolt passes an 8 mm spacer, panel, rail and extension; rear bolt passes a 4 mm spacer, outer fan foot, crossmember and extension. Guide clears rail flange by 1 mm. Air sealing/thermal effectiveness are unqualified.") +} +fn guide_shim(sx:Int,front:Bool,id:String) -> AssemblyPart { + let height=if front {8000} else {4000} + part(id:id,name:"Rear guide spacer",kind:Printed,scope:Cassette, + adds:[round(diameter:8000,length:height,axis:Axis3Y,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z:0)], + x:sx*(tray_w/2-7000),y:13000-height/2,z:if front {0-tray_d/2+20000} else {rear_beam_z},ex:sx*75000,ey:15000,ez: -55000,plane:LayOnXZ,notes:"Derived guide support stack: front rests on panel, rear on outer fan foot. Both top faces at 13 mm, clearing rail flange.") +} +fn printed_parts() -> List { + concat([ + panel(sx: -1,sz: -1,id:"P1"),panel(sx:1,sz: -1,id:"P2"),panel(sx: -1,sz:1,id:"P3"),panel(sx:1,sz:1,id:"P4"), + panel_bridge(sx:0,sz: -1,id:"J1"),panel_bridge(sx:0,sz:1,id:"J2"),panel_bridge(sx: -1,sz:0,id:"J3"),panel_bridge(sx:1,sz:0,id:"J4"), + rail(sx: -1,sz: -1,id:"R1"),rail(sx: -1,sz:1,id:"R2"),rail(sx:1,sz: -1,id:"R3"),rail(sx:1,sz:1,id:"R4"),rail_bridge(sx: -1,id:"RJ1"),rail_bridge(sx:1,id:"RJ2"), + guide_shim(sx: -1,front:false,id:"DS1"),guide_shim(sx:1,front:false,id:"DS2"),guide_shim(sx: -1,front:true,id:"DS3"),guide_shim(sx:1,front:true,id:"DS4"),handle(),retaining_tab(sx: -1,id:"T1"),retaining_tab(sx:1,id:"T2"),extension(sx: -1,id:"E1"),extension(sx:1,id:"E2"),fan_beam(sx: -1,id:"FB1"),fan_beam(sx:1,id:"FB2"),fan_beam_bridge(), + fan_plate(i: -1,id:"M1"),fan_plate(i:0,id:"M2"),fan_plate(i:1,id:"M3"),fan_foot(i: -1,id:"MF1"),fan_foot(i:0,id:"MF2"),fan_foot(i:1,id:"MF3"), + fan_guard(i: -1,id:"G1"),fan_guard(i:0,id:"G2"),fan_guard(i:1,id:"G3"),guide(sx: -1,id:"D1"),guide(sx:1,id:"D2") + ],fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,fold([-1,1],init:[],f:fn(inner,x){concat(inner,fold([-1,1],init:[],f:fn(last,y){concat(last,[guard_spacer(i:i,sx:x,sy:y,id:join(["SP",to_string(i),to_string(x),to_string(y)],"_"))])}))}))})) +} + +fn interfaces() -> List { + [mechanical_interface(id:"panel-seams",members:["P1","P2","P3","P4","J1","J2","J3","J4"],fastener:"M3 bolt + washer + nut; length selected from stack",quantity:16,status:"proposed",requirement:"Matched 3.4 mm through-holes; panel seam gap 2 mm."), + mechanical_interface(id:"panel-rails",members:["P1","P2","P3","P4","R1","R2","R3","R4"],fastener:"M3 bolt + washer + nut",quantity:8,status:"proposed",requirement:"9 mm nominal plastic stack; no screw-tip contact with board."), + mechanical_interface(id:"rail-splices",members:["R1","R2","R3","R4","RJ1","RJ2"],fastener:"M3 bolt + washer + nut",quantity:4,status:"proposed",requirement:"Two bolts per splice; 8 mm stack."), + mechanical_interface(id:"handle",members:["H1","P3","P4"],fastener:"M3 bolt + washer + nut",quantity:2,status:"proposed",requirement:"15 mm stack; pull/load qualification pending."), + mechanical_interface(id:"retention",members:["T1","T2","P3","P4","FRAME"],fastener:"M3 pivot bolt + washer + locknut",quantity:2,status:"proposed",requirement:"Rack-stop engagement and extraction clearance to review."), + mechanical_interface(id:"rear-extensions",members:["E1","E2","R1","R3","P1","P2","D1","D2","DS1","DS2","DS3","DS4","MF1","MF3","FB1","FB2"],fastener:"M3 through-bolts + nuts; guide spacers",quantity:4,status:"proposed",requirement:"Front and rear shared stacks; verify guide spacers and accessible nuts."), + mechanical_interface(id:"fan-beam-splice",members:["FB1","FB2","FBJ"],fastener:"M3 bolt + washer + nut",quantity:2,status:"proposed",requirement:"12 mm stack."), + mechanical_interface(id:"fan-feet",members:["MF1","MF2","MF3","FB1","FB2"],fastener:"M3 bolt + washer + nut",quantity:6,status:"proposed",requirement:"13 mm stack, two bolts per fan foot."), + mechanical_interface(id:"fan-pattern",members:["F1","F2","F3","M1","M2","M3","MF1","MF2","MF3","G1","G2","G3"],fastener:"M4 through-bolts + nuts; 7 mm upper / 2 mm lower guard spacers",quantity:12,status:"plate fit passed; new stack proposed",requirement:"39 mm nominal stack before washers/nut; assess M4x45 engagement and actual fan fastener path."), + mechanical_interface(id:"board-support",members:["BOARD","P1","P2","P3","P4"],fastener:"Standoffs and adjustable support hardware not selected",quantity:0,status:"unresolved",requirement:"Known 1–3 and 3–4 alignment passes; no further gauges requested. Underside keep-outs/thread/height required before populated operation. No fabricated standoff geometry."), + mechanical_interface(id:"power",members:["PSU","PDB","DC","FEEDS"],fastener:"Four rated protected/isolatable 12 V branches",quantity:4,status:"unresolved",requirement:"Shared supply per block. No wattage, wire gauge, connector pinout or live hot-swap admission."), + mechanical_interface(id:"rack-ground",members:["FRAME","SHELF","PSU"],fastener:"Dedicated protective-earth bonding hardware",quantity:0,status:"unresolved",requirement:"Bond persists with every cassette removed; printed polymer is not the bond or mains enclosure.")] +} diff --git a/dag/gunbc/product/printed_chassis/cassette_review.dag b/dag/gunbc/product/printed_chassis/cassette_review.dag new file mode 100644 index 00000000000..7f3f2cdf2a1 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/cassette_review.dag @@ -0,0 +1,47 @@ +module product.printed_chassis.cassette_review + +import std.types { Int, String, List } +import std.measure { micrometer_count, millimeter_count } +import std.affine_space { Point3, signed_frame_component_count } +import std.euclidean_geometry { Axis3X, Axis3Y, Axis3Z } +import extdeps.languages.json.emit { JsonValue,json_object,json_kv,json_string,json_int,json_array,json_bool,json_null,serialize_json } +import gunbc.cli_wire { CliWireResponse,CliWirePrintable } +import std.process { ExitSuccess } +import product.printed_chassis.assembly_geometry { Primitive,Block,RoundPrism,AssemblyPart,MechanicalInterface,Printed,Purchased,Metal,Clearance,Cassette,FixedFrame,LayOnXZ,LayOnXY,LayOnYZ } +import product.printed_chassis.cassette { printed_parts,interfaces,board_w,board_d,tray_w,tray_d,bay_w,bay_pitch,fan_fit_observation,m3_clearance,fan_hole_d } +import product.printed_chassis.rack_assembly { hardware,fixed_parts } +import product.printed_chassis.export_profile { exercised_stl_knobs } +import extdeps.printing.bambu_lab_a1_mini { a1_mini_build_envelope } +import extdeps.printing.fdm { build_envelope_x,build_envelope_y,build_envelope_z } + +fn xyz(p:Point3) -> JsonValue { json_array(elements:[json_int(n:signed_frame_component_count(c:p.x)),json_int(n:signed_frame_component_count(c:p.y)),json_int(n:signed_frame_component_count(c:p.z))]) } +fn text(s:String) -> JsonValue { json_string(s:s) } +fn primitive_json(p:Primitive) -> JsonValue { + match p { + Block {size:s,center:c} => json_object(members:[json_kv(key:"op",value:text(s:"box")),json_kv(key:"size_um",value:json_array(elements:[json_int(n:micrometer_count(m:s.size_x)),json_int(n:micrometer_count(m:s.size_y)),json_int(n:micrometer_count(m:s.size_z))])),json_kv(key:"center_um",value:xyz(p:c))]), + RoundPrism {diameter:d,length:l,axis:a,center:c} => json_object(members:[json_kv(key:"op",value:text(s:"cylinder")),json_kv(key:"diameter_um",value:json_int(n:micrometer_count(m:d))),json_kv(key:"length_um",value:json_int(n:micrometer_count(m:l))),json_kv(key:"axis",value:text(s:match a {Axis3X=>"x",Axis3Y=>"y",Axis3Z=>"z"})),json_kv(key:"center_um",value:xyz(p:c))]), + } +} +fn part_json(p:AssemblyPart) -> JsonValue { + json_object(members:[json_kv(key:"id",value:text(s:p.id)),json_kv(key:"name",value:text(s:p.name)),json_kv(key:"kind",value:text(s:match p.kind {Printed=>"printed",Purchased=>"purchased",Metal=>"metal",Clearance=>"clearance"})),json_kv(key:"scope",value:text(s:match p.scope {Cassette=>"cassette",FixedFrame=>"fixed"})),json_kv(key:"adds",value:json_array(elements:p.adds |> map(a=>primitive_json(p:a)))),json_kv(key:"cuts",value:json_array(elements:p.cuts |> map(a=>primitive_json(p:a)))),json_kv(key:"at_um",value:xyz(p:p.at)),json_kv(key:"explode_um",value:xyz(p:p.explode)),json_kv(key:"print_plane",value:text(s:match p.print_plane {LayOnXZ=>"XZ",LayOnXY=>"XY",LayOnYZ=>"YZ"})),json_kv(key:"notes",value:text(s:p.notes))]) +} +fn interface_json(i:MechanicalInterface) -> JsonValue { + json_object(members:[json_kv(key:"id",value:text(s:i.id)),json_kv(key:"members",value:json_array(elements:i.members |> map(x=>text(s:x)))),json_kv(key:"fastener",value:text(s:i.fastener)),json_kv(key:"quantity",value:json_int(n:i.quantity)),json_kv(key:"status",value:text(s:i.status)),json_kv(key:"requirement",value:text(s:i.requirement))]) +} +fn all_parts() -> List { concat(printed_parts(),concat(hardware(),fixed_parts())) } +fn vector(x:Int,y:Int,z:Int) -> JsonValue { json_array(elements:[json_int(n:x),json_int(n:y),json_int(n:z)]) } +fn node(id:String,x:Int,y:Int) -> JsonValue { json_object(members:[json_kv(key:"id",value:text(s:id)),json_kv(key:"at_um",value:vector(x:x,y:y,z:0))]) } +fn airflow(x:Int) -> JsonValue { json_object(members:[json_kv(key:"direction",value:vector(x:0,y:0,z:-1)),json_kv(key:"start_um",value:vector(x:x,y:105000,z:tray_d/2+55000)),json_kv(key:"length_um",value:json_int(n:tray_d+140000))]) } +fn manifest() -> JsonValue { + json_object(members:[json_kv(key:"schema",value:text(s:"gunbc.cassette-review.v1")),json_kv(key:"revision",value:text(s:"05")),json_kv(key:"quantum",value:text(s:"micrometre")),json_kv(key:"axes",value:text(s:"X across rear I/O; Y up; +Z toward front handle")),json_kv(key:"release",value:text(s:"review-only; no print or powered-operation admission")),json_kv(key:"fan_fit",value:text(s:fan_fit_observation)), + json_kv(key:"board_um",value:json_array(elements:[json_int(n:board_w),json_int(n:board_d)])),json_kv(key:"tray_um",value:json_array(elements:[json_int(n:tray_w),json_int(n:tray_d)])),json_kv(key:"bay_pitch_um",value:json_int(n:bay_pitch)),json_kv(key:"bay_width_um",value:json_int(n:bay_w)),json_kv(key:"nodes_per_block",value:json_int(n:4)),json_kv(key:"intended_nodes",value:json_int(n:8)), + json_kv(key:"fastener_clearance_um",value:json_array(elements:[json_int(n:m3_clearance),json_int(n:fan_hole_d)])), + json_kv(key:"nodes",value:json_array(elements:[node(id:"N1",x:-bay_w/2,y:0),node(id:"N2",x:bay_w/2,y:0),node(id:"N3",x:-bay_w/2,y:bay_pitch),node(id:"N4",x:bay_w/2,y:bay_pitch)])), + json_kv(key:"airflow",value:json_array(elements:[airflow(x:-75000),airflow(x:75000)])), + json_kv(key:"export",value:json_object(members:[json_kv(key:"linear_tolerance_um",value:json_int(n:micrometer_count(m:exercised_stl_knobs.linear_tolerance))),json_kv(key:"angular_tolerance_radians",value:text(s:exercised_stl_knobs.angular_tolerance_text as String))])), + json_kv(key:"power",value:json_object(members:[json_kv(key:"placement",value:text(s:"fixed-frame shared 12 V system per four-node block")),json_kv(key:"branches",value:json_int(n:4)),json_kv(key:"rated_watts",value:json_null()),json_kv(key:"live_hot_swap",value:json_bool(b:false)),json_kv(key:"redundancy",value:json_null())])), + json_kv(key:"print_envelope_mm",value:json_array(elements:[json_int(n:millimeter_count(m:build_envelope_x(e:a1_mini_build_envelope))),json_int(n:millimeter_count(m:build_envelope_y(e:a1_mini_build_envelope))),json_int(n:millimeter_count(m:build_envelope_z(e:a1_mini_build_envelope)))])), + json_kv(key:"parts",value:json_array(elements:all_parts() |> map(p=>part_json(p:p)))),json_kv(key:"interfaces",value:json_array(elements:interfaces() |> map(i=>interface_json(i:i)))), + json_kv(key:"unresolved",value:json_array(elements:[text(s:"Board standoff thread, installed height and underside keep-outs; no new gauge sequence."),text(s:"Fastener lengths/engagement, support material, rail clearance and loaded joint performance."),text(s:"Cooler location/orientation and rear I/O/cable service access."),text(s:"Shared supply selection, branch circuitry, protective earth and thermal validation."),text(s:"Drives and expansion cards are unspecified, not silently omitted requirements.")]))]) +} +fn main() -> CliWireResponse { CliWirePrintable {bytes:serialize_json(v:manifest()),exit:ExitSuccess} } diff --git a/dag/gunbc/product/printed_chassis/rack_assembly.dag b/dag/gunbc/product/printed_chassis/rack_assembly.dag new file mode 100644 index 00000000000..77ad4c2d241 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/rack_assembly.dag @@ -0,0 +1,45 @@ +module product.printed_chassis.rack_assembly + +import std.types { Int, String, List } +import std.measure { micrometer_count } +import extdeps.cooling.dynatron { dynatron_w1_active_length,dynatron_w1_active_width,dynatron_w1_active_height } +import extdeps.fans.arctic { arctic_p8_frame_depth,arctic_p8_mounting_pattern } +import std.euclidean_geometry { Axis3Y, Axis3Z } +import product.printed_chassis.assembly_geometry { AssemblyPart, Primitive, Purchased, Metal, Clearance, Cassette, FixedFrame, LayOnXZ, LayOnXY, block,round,part } +import product.printed_chassis.cassette { tray_w,tray_d,board_w,board_d,bay_pitch,bay_w,rear_plate_z,fan_spacing } + +// Hardware bodies are catalog envelopes or explicit space reservations, not manufacturing CAD. +// Positions are design proposals; drives, GPUs and real connector pinouts remain unresolved. +fn hardware() -> List { + concat([ + part(id:"BOARD",name:"ALTRAD8UD-1L2T reference",kind:Clearance,scope:Cassette,adds:[block(w:board_w,h:1000,d:board_d,x:0,y:0,z:0)],cuts:[],x:0,y:27000,z:0,ex:0,ey:90000,ez:0,plane:LayOnXZ,notes:"Nominal board outline from vendor authority. 1 mm display slab is not measured PCB thickness. Elevation and socket/component placement provisional; board support hardware is unresolved."), + part(id:"CPU",name:"Dynatron W1 active envelope",kind:Purchased,scope:Cassette,adds:[block(w:micrometer_count(m:dynatron_w1_active_width),h:micrometer_count(m:dynatron_w1_active_height),d:micrometer_count(m:dynatron_w1_active_length),x:0,y:micrometer_count(m:dynatron_w1_active_height)/2,z:0)],cuts:[],x:0,y:30000,z:0,ex:0,ey:140000,ez:0,plane:LayOnXZ,notes:"Manufacturer active envelope 141.1 x 84 x 72 mm (dynatron.co/product-page/w1); simplified envelope. Location, orientation and installed offset provisional."), + part(id:"RAM1",name:"Left DIMM clearance",kind:Clearance,scope:Cassette,adds:[block(w:25000,h:40000,d:140000,x:0,y:20000,z:0)],cuts:[],x: -87000,y:27000,z:0,ex: -35000,ey:70000,ez:0,plane:LayOnXZ,notes:"Space reservation only; not surveyed DIMM positions or height."), + part(id:"RAM2",name:"Right DIMM clearance",kind:Clearance,scope:Cassette,adds:[block(w:25000,h:40000,d:140000,x:0,y:20000,z:0)],cuts:[],x:87000,y:27000,z:0,ex:35000,ey:70000,ez:0,plane:LayOnXZ,notes:"Space reservation only; not surveyed DIMM positions or height."), + part(id:"DC",name:"Isolatable DC disconnect reservation",kind:Clearance,scope:Cassette,adds:[block(w:24000,h:20000,d:18000,x:0,y:0,z:0)],cuts:[],x:tray_w/2-10000,y:22000,z:0-tray_d/2-12000,ex:45000,ey:30000,ez:0,plane:LayOnXZ,notes:"Rated/keyed connector not selected. One protected 12 V feed per node; shut down, isolate and disconnect before extraction. Not hot-swap."), + part(id:"CABLE",name:"Node harness corridor",kind:Clearance,scope:Cassette,adds:[block(w:10000,h:10000,d:tray_d-50000,x:0,y:0,z:0)],cuts:[],x:tray_w/2-15000,y:18000,z: -10000,ex:45000,ey:20000,ez:0,plane:LayOnXZ,notes:"Reserved route, not actual harness CAD; connector endpoints and bend radii pending."), + part(id:"IO",name:"Rear I/O service volume",kind:Clearance,scope:Cassette,adds:[block(w:board_w,h:45000,d:50000,x:0,y:0,z:0)],cuts:[],x:0,y:50000,z:0-tray_d/2-25000,ex:0,ey:40000,ez: -20000,plane:LayOnXZ,notes:"50 mm proposed service space ahead of rear fan bank. Actual rear connectors, cable bends and hand access require assembly review; this is not a measured keep-out.") + ],fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,[ + part(id:concat("F",to_string(i+2)),name:"ARCTIC P8 rear exhaust",kind:Purchased,scope:Cassette, + adds:[block(w:micrometer_count(m:arctic_p8_mounting_pattern.frame_edge),h:micrometer_count(m:arctic_p8_mounting_pattern.frame_edge),d:micrometer_count(m:arctic_p8_frame_depth),x:0,y:0,z:0)],cuts:[round(diameter:73000,length:30000,axis:Axis3Z,x:0,y:0,z:0)], + x:i*fan_spacing,y:55000,z:rear_plate_z+2000+micrometer_count(m:arctic_p8_frame_depth)/2,ex:i*40000,ey:0,ez: -25000,plane:LayOnXY, + notes:"80 x 80 x 25 mm catalog frame; rotor omitted. Rear exhaust, three-fan layout proposal; thermal adequacy and PST header loading unqualified.") + ])})) +} +fn fixed_parts() -> List { + let rack_d=tray_d+150000 + let cz= -30000 + let posts=fold([-1,0,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,z){concat(inner,[block(w:14000,h:bay_pitch*2+45000,d:14000,x:x*bay_w,y:bay_pitch-12000,z:cz+z*(rack_d/2+18000))])}))}) + let beams=fold([-28000,bay_pitch-28000,bay_pitch*2+10000],init:[],f:fn(acc,y){concat(acc,concat( + fold([-1,1],init:[],f:fn(inner,z){concat(inner,[block(w:bay_w*2+14000,h:12000,d:14000,x:0,y:y,z:cz+z*(rack_d/2+18000))])}), + fold([-1,0,1],init:[],f:fn(inner,x){concat(inner,[block(w:14000,h:12000,d:rack_d+36000,x:x*bay_w,y:y,z:cz)])}) + ))}) + // Two fixed runners per node contact the outboard strip of each printed rail. + let runners=fold([-1,1],init:[],f:fn(acc,col){concat(acc,fold([0,1],init:[],f:fn(inner,row){concat(inner,fold([-1,1],init:[],f:fn(last,side){concat(last,[block(w:8000,h:6000,d:rack_d+36000,x:col*bay_w/2+side*(tray_w/2+6000),y:row*bay_pitch-7000,z:cz)])}))}))}) + let feeds=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([0,1],init:[],f:fn(inner,row){concat(inner,[block(w:5000,h:bay_pitch*2+20000-row*bay_pitch,d:5000,x:x*bay_w/2+tray_w/2-10000,y:(row*bay_pitch+bay_pitch*2+64000)/2,z:0-tray_d/2-35000)])}))}) + [part(id:"FRAME",name:"Fixed metal 2 x 2 frame",kind:Metal,scope:FixedFrame,adds:concat(posts,concat(beams,runners)),cuts:[],x:0,y:0,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Metal stock proposal: posts 14 mm square and beams 14 x 12 mm. Cut lengths derived; 8 x 6 mm fixed runner proposals contact the outer printed rail strip. Actual stock, runner brackets, joining and stiffness to be selected. Not printable."), + part(id:"SHELF",name:"Shared power shelf",kind:Metal,scope:FixedFrame,adds:[block(w:bay_w*2,h:8000,d:rack_d,x:0,y:0,z:0)],cuts:[],x:0,y:bay_pitch*2+28000,z:cz,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Top shelf proposal; metal gauge and fastening unqualified. Fixed protective earth must survive cassette removal."), + part(id:"PSU",name:"Shared 12 V supply reservation",kind:Clearance,scope:FixedFrame,adds:[block(w:150000,h:85000,d:150000,x:0,y:42500,z:0)],cuts:[],x:0,y:bay_pitch*2+32000,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"One supply system per four nodes. 150 x 85 x 150 mm is a space reservation, not a selected model. Wattage and redundancy unresolved; 400-600 W per-node example is superseded."), + part(id:"PDB",name:"Four-branch distribution reservation",kind:Clearance,scope:FixedFrame,adds:[block(w:150000,h:30000,d:35000,x:0,y:0,z:0)],cuts:[],x:0,y:bay_pitch*2+55000,z:0-rack_d/2-15000,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Four protected, isolatable branches. No circuit, ratings, wire gauge or connector pinouts invented."), + part(id:"FEEDS",name:"Fixed branch route reservations",kind:Clearance,scope:FixedFrame,adds:feeds,cuts:[],x:0,y:0,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Four schematic branch corridors remain on the frame when a node is removed. Actual distribution routing and disconnect positioning require review.")] +} diff --git a/dag/gunbc/product/printed_chassis/review_realization.dag b/dag/gunbc/product/printed_chassis/review_realization.dag new file mode 100644 index 00000000000..667765b2695 --- /dev/null +++ b/dag/gunbc/product/printed_chassis/review_realization.dag @@ -0,0 +1,195 @@ +module product.printed_chassis.review_realization + +import std.types { String } +import gunbc.cli_wire { CliWireResponse, CliWirePrintable } +import std.process { ExitSuccess } + +// Review-only host adapter. Mechanical geometry, placements, materials and tolerances +// are inputs from cassette_review, never decisions made here. This emits no printer +// command and grants no AdmittedRealizationV0 / fabrication capability. The existing +// admission workflow remains mandatory after assembly review and before printing. +// Exercise with CadQuery 2.8.0; rejection is atomic (no partial review package). +// Python is emitted transiently because this repository owns tools in .dag/.rs. +fn adapter_source() -> String { join([ + "#!/usr/bin/env python3\n", + "\"\"\"Realize a DAG-emitted review manifest. No cassette dimensions live in this adapter.\n", + "\n", + "CadQuery 2.8.0 is the exercised kernel. Only the closed box/cylinder union/difference\n", + "vocabulary is accepted; unknown operations, disconnected printed solids and oversized\n", + "parts refuse. This is review-output validation, not powered/structural qualification.\n", + "\"\"\"\n", + "import argparse, collections, csv, hashlib, io, json, math, pathlib, re, shutil, tempfile, zipfile\n", + "import cadquery as cq\n", + "from OCP.Bnd import Bnd_Box\n", + "from OCP.BRepBndLib import BRepBndLib\n", + "from vtkmodules.vtkIOGeometry import vtkSTLReader\n", + "from vtkmodules.vtkFiltersCore import vtkFeatureEdges, vtkMassProperties\n", + "\n", + "\n", + "def mm(values):\n", + " assert all(type(v) is int for v in values), 'Expected integer micrometres'\n", + " return [v / 1000 for v in values]\n", + "\n", + "\n", + "def primitive(row):\n", + " center = mm(row['center_um'])\n", + " if row['op'] == 'box':\n", + " size = mm(row['size_um']); assert min(size) > 0\n", + " return cq.Workplane('XY').box(*size).val().translate(center)\n", + " if row['op'] == 'cylinder':\n", + " diameter, length = mm([row['diameter_um'], row['length_um']]); assert min(diameter,length) > 0\n", + " axes = \{'x':(1,0,0), 'y':(0,1,0), 'z':(0,0,1)\}\n", + " axis = axes[row['axis']]\n", + " start = tuple(c-a*length/2 for c,a in zip(center,axis))\n", + " return cq.Solid.makeCylinder(diameter/2, length, cq.Vector(*start), cq.Vector(*axis))\n", + " raise ValueError(f\"Unknown modeled primitive: \{row['op']\}\")\n", + "\n", + "\n", + "def exact_bounds(shape):\n", + " b=Bnd_Box(); BRepBndLib.AddOptimal_s(shape.wrapped,b,False,False)\n", + " return b.Get()\n", + "\n", + "def extent(shape):\n", + " b=exact_bounds(shape); return [b[i+3]-b[i] for i in range(3)]\n", + "\n", + "\n", + "def near(a,b,tolerance=2e-5):\n", + " assert len(a)==len(b) and all(abs(x-y)0\n", + " e=vtkFeatureEdges(); e.SetInputData(mesh); e.BoundaryEdgesOn(); e.NonManifoldEdgesOn(); e.FeatureEdgesOff(); e.ManifoldEdgesOff(); e.Update()\n", + " assert e.GetOutput().GetNumberOfCells()==0, 'Open/nonmanifold STL'\n", + " b=mesh.GetBounds(); near([b[1]-b[0],b[3]-b[2],b[5]-b[4]],extent(shape),2*tolerance+2e-5)\n", + " mass=vtkMassProperties();mass.SetInputData(mesh);mass.Update()\n", + " assert math.isclose(mass.GetVolume(),shape.Volume(),rel_tol=3e-4), 'STL volume divergence'\n", + " return mesh.GetNumberOfCells()\n", + "\n", + "\n", + "def primitive_bounds(p):\n", + " c=mm(p['center_um'])\n", + " if p['op']=='box': s=mm(p['size_um'])\n", + " else:\n", + " s=[p['diameter_um']/1000]*3;s['xyz'.index(p['axis'])]=p['length_um']/1000\n", + " return [c[i]-s[i]/2 for i in range(3)]+[c[i]+s[i]/2 for i in range(3)]\n", + "\n", + "\n", + "def model_bounds(p):\n", + " b=[primitive_bounds(a) for a in p['adds']]\n", + " return [min(r[i] for r in b) for i in range(3)]+[max(r[i+3] for r in b) for i in range(3)]\n", + "\n", + "\n", + "def overlaps(a,b):\n", + " return all(min(a[i+3],b[i+3])-max(a[i],b[i])>1e-5 for i in range(3))\n", + "\n", + "\n", + "def realize(model, out):\n", + " assert model['schema']=='gunbc.cassette-review.v1' and model['quantum']=='micrometre'\n", + " assert model['release']=='review-only; no print or powered-operation admission'\n", + " assert cq.__version__=='2.8.0', 'Use the recorded CadQuery 2.8.0 environment'\n", + " ids=[p['id'] for p in model['parts']];assert len(set(ids))==len(ids)\n", + " assert all(re.fullmatch(r'[A-Za-z0-9_-]+',i) for i in ids)\n", + " for interface in model['interfaces']:\n", + " assert set(interface['members'])<=set(ids), interface\n", + " # Every modeled fastener bore must have a coaxial mate in another printed part.\n", + " # Diameter classes come from the logical model, not adapter design choices.\n", + " axes=collections.defaultdict(set)\n", + " for p in model['parts']:\n", + " if p['kind']!='printed':continue\n", + " for c in p['cuts']:\n", + " if c['op']=='cylinder' and c['diameter_um'] in model['fastener_clearance_um']:\n", + " xyz=[a+b for a,b in zip(c['center_um'],p['at_um'])]\n", + " axial='xyz'.index(c['axis'])\n", + " key=(c['axis'],c['diameter_um'],*[v for i,v in enumerate(xyz) if i!=axial])\n", + " axes[key].add(p['id'])\n", + " assert all(len(v)>=2 for v in axes.values()), 'Fastener bore without coaxial mate'\n", + " shapes=\{\}; world=\{\}; bounds=\{\}; groups=\{\}; errors=[]\n", + " tol=model['export']['linear_tolerance_um']/1000\n", + " angular=float(model['export']['angular_tolerance_radians'])\n", + " for p in model['parts']:\n", + " try:\n", + " assert p['adds'], 'No positive geometry (upstream admission refused?)'\n", + " body=primitive(p['adds'][0])\n", + " for a in p['adds'][1:]: body=body.fuse(primitive(a))\n", + " body=body.clean()\n", + " for cut in p['cuts']:\n", + " tool=primitive(cut)\n", + " assert body.intersect(tool).Volume()>1e-6, f'Cut misses material: \{cut\}'\n", + " body=body.cut(tool)\n", + " body=body.clean(); assert body.isValid() and body.Volume()>0\n", + " if p['kind']=='printed': assert len(body.Solids())==1, f'\{len(body.Solids())\} disconnected solids'\n", + " expected=model_bounds(p)\n", + " near(extent(body),[expected[i+3]-expected[i] for i in range(3)])\n", + " vertices, faces=body.tessellate(tol,angular)\n", + " mesh=\{'vertices':[c for v in vertices for c in v.toTuple()], 'indices':[i for f in faces for i in f]\}\n", + " (out/f'\{p[\"id\"]\}.mesh.json').write_text(json.dumps(mesh,separators=(',',':')))\n", + " p['mesh']=f'\{p[\"id\"]\}.mesh.json';p['extent_mm']=extent(body);p['volume_mm3']=body.Volume()\n", + " shapes[p['id']]=body;at=mm(p['at_um']);world[p['id']]=body.translate(at)\n", + " bounds[p['id']]=[expected[i]+at[i%3] for i in range(6)]\n", + " if p['kind']=='printed':\n", + " oriented=body\n", + " if p['print_plane']=='XZ':oriented=body.rotate((0,0,0),(1,0,0),90)\n", + " elif p['print_plane']=='YZ':oriented=body.rotate((0,0,0),(0,1,0),-90)\n", + " else:assert p['print_plane']=='XY'\n", + " b=exact_bounds(oriented);oriented=oriented.translate(tuple(-v for v in b[:3]))\n", + " assert all(n<=limit for n,limit in zip(extent(oriented),model['print_envelope_mm'])), 'Build envelope exceeded'\n", + " stl=out/f'\{p[\"id\"]\}.stl';oriented.exportStl(str(stl),tolerance=tol,angularTolerance=angular,ascii=False,relative=False,parallel=True)\n", + " triangles=mesh_check(stl,oriented,tol)\n", + " step=out/f'\{p[\"id\"]\}.step';cq.exporters.export(body,str(step),exportType='STEP')\n", + " readback=cq.importers.importStep(str(step)).val();near(extent(readback),extent(body))\n", + " assert math.isclose(readback.Volume(),body.Volume(),rel_tol=1e-8)\n", + " p.update(stl=stl.name,step=step.name,print_extent_mm=extent(oriented),triangles=triangles,sha256=hashlib.sha256(stl.read_bytes()).hexdigest(),checks='single solid; STEP round-trip; closed STL; volume/bounds; bed fit')\n", + " identity=hashlib.sha256(json.dumps(\{k:p[k] for k in ['adds','cuts','print_plane']\},sort_keys=True).encode()).hexdigest()\n", + " groups.setdefault(identity,[]).append(p['id'])\n", + " print('OK',p['id'],p['name'],flush=True)\n", + " except Exception as exc:\n", + " errors.append(f'\{p[\"id\"]\}: \{exc\}');print('REFUSED',errors[-1],flush=True)\n", + " if errors: raise ValueError('\\n'.join(errors))\n", + " # Exact CAD intersections between moving printed parts, after a broad-phase AABB check.\n", + " printed=[p for p in model['parts'] if p['kind']=='printed'];collisions=[]\n", + " for n,a in enumerate(printed):\n", + " for b in printed[n+1:]:\n", + " if overlaps(bounds[a['id']],bounds[b['id']]):\n", + " volume=world[a['id']].intersect(world[b['id']]).Volume()\n", + " if volume>1e-4:collisions.append(\{'a':a['id'],'b':b['id'],'volume_mm3':volume\})\n", + " if collisions:raise ValueError('Printed-part collisions: '+json.dumps(collisions))\n", + " model['validation']=\{'cadquery':cq.__version__,'printed_part_count':len(printed),'unique_print_geometries':len(groups),'printed_pair_collisions':collisions,'matched_fastener_axes':len(axes),'scope':'geometry only; no fastener engagement, thermal, structural or electrical qualification'\}\n", + " model['print_groups']=[\{'representative':items[0],'quantity':len(items),'instances':items\} for items in groups.values()]\n", + " assembly=cq.Assembly()\n", + " for p in model['parts']:\n", + " if p['scope']=='cassette' and p['kind']!='clearance':assembly.add(shapes[p['id']],name=p['id'],loc=cq.Location(cq.Vector(*mm(p['at_um']))))\n", + " assembly.export(str(out/'cassette-assembly.step'))\n", + " (out/'assembly.json').write_text(json.dumps(model,indent=2)+'\\n')\n", + " with (out/'parts.csv').open('w') as f:\n", + " w=csv.writer(f);w.writerow(['id','part','kind','scope','x_mm','y_mm','z_mm','notes'])\n", + " for p in model['parts']:w.writerow([p['id'],p['name'],p['kind'],p['scope'],*p['extent_mm'],p['notes']])\n", + " with (out/'interfaces.csv').open('w') as f:\n", + " w=csv.writer(f);w.writerow(['interface','members','fastener','quantity','status','requirement'])\n", + " for i in model['interfaces']:w.writerow([i['id'],' '.join(i['members']),i['fastener'],i['quantity'],i['status'],i['requirement']])\n", + " with (out/'print-list.csv').open('w') as f:\n", + " w=csv.writer(f);w.writerow(['representative','quantity','instances','print_file'])\n", + " for g in model['print_groups']:w.writerow([g['representative'],g['quantity'],' '.join(g['instances']),g['representative']+'.stl'])\n", + " (out/'README.txt').write_text('Cassette r05 review package. Generated from product.printed_chassis.cassette_review.\\n'+model['release']+'\\nSTLs are bed-oriented; individual STEPs and assembly STEP use assembly-local axes.\\nparts.csv / print-list.csv list all parts and repeat quantities. One M fan plate is already fit-tested.\\nNo additional gauge sequence. Review assembly, fasteners and unresolved board support before scheduling cassette prints.\\n')\n", + " with zipfile.ZipFile(out/'cassette-review.zip','w',zipfile.ZIP_DEFLATED) as z:\n", + " for f in sorted(out.iterdir()):\n", + " if f.suffix in ['.stl','.step','.csv','.txt'] or f.name=='assembly.json':z.write(f,f.name)\n", + "\n", + "\n", + "def main():\n", + " ap=argparse.ArgumentParser();ap.add_argument('manifest',type=pathlib.Path);ap.add_argument('output',type=pathlib.Path);args=ap.parse_args()\n", + " model=json.loads(args.manifest.read_text());model['source_manifest_sha256']=hashlib.sha256(args.manifest.read_bytes()).hexdigest()\n", + " args.output.parent.mkdir(parents=True,exist_ok=True)\n", + " staging=pathlib.Path(tempfile.mkdtemp(prefix='cad-review-',dir=args.output.parent))\n", + " try:\n", + " realize(model,staging)\n", + " if args.output.exists():raise ValueError('Output already exists; choose a fresh version directory')\n", + " staging.rename(args.output)\n", + " except Exception:\n", + " shutil.rmtree(staging);raise\n", + " print(json.dumps(model['validation']))\n", + "\n", + "if __name__=='__main__':main()\n" +], "") } +fn main() -> CliWireResponse { CliWirePrintable {bytes:adapter_source(),exit:ExitSuccess} } diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 778f0039248..52d7fb055ca 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1280,3 +1280,74 @@ print_error 0, exact digest filename `gunbc-71da943ff2ed73a6b47c54759ba288e82af7 not yet evidence of deposited layers or completed fit. The snapshot is retained locally and on srv1. The operator independently reported probably heating. Printer-02 was not started. Temporary copies of the operator token were deleted from both hosts. + +### Complete logical cassette review — 4 October 2026 + +The operator reports that the printed rear fan plate fits. Record this as a physical fit +observation, not a caliper measurement or a promotion of the upstream conventional hole pattern. +The operator requests the whole cassette model now, review of all pieces together, then the first +cassette print. Stop the individual gauge sequence. No printer start is authorized by this review +artifact generation alone; the next print follows assembly review and the existing print workflow. + +Review 05 replaces browser-authored mechanical geometry with `product.printed_chassis.cassette` +and `rack_assembly`. `assembly_geometry` carries typed box/cylinder CSG, micrometre coordinates, +part kind, fixed/moving scope, print orientation and mating-interface records. `cassette_review` +emits the full manifest. `review_realization` emits the generic CadQuery host adapter; it chooses +no mechanical dimensions and grants no fabrication admission. The old Site `concept.js` is removed. +Vendor board outline, fan plate/pattern, W1 envelope and P8 depth resolve from their existing owners. + +One cassette has 49 printed instances / 29 distinct authored geometries: four vented tray panels, +four panel splices, four rails, two rail splices, a handle, two retaining stops, two rear extension +arms, two fan crossmember halves and a splice, three fitted-pattern fan plates, three feet, three +guards, two side airflow guides, four guide spacers and twelve guard spacers. The tray is +271.840 × 294.700 mm. Each piece fits the 180 mm A1 mini envelope in its declared orientation. +The two-level block retains a 140 mm bay pitch; this is a custom frame, not a claim of standard +2U/4U rack compliance. The fixed metal frame includes runner proposals. Shared 12 V supply, +four protected/isolatable feeds and distribution space remain fixed when a cassette is extracted. +Supply dimensions are explicitly space reservations; no supply wattage or circuit is selected. + +The guides initially intersected the rail flanges and outer fan feet. The corrected model raises +the guide foot to 13 mm, adds front 8 mm / rear 4 mm spacers, and drills the outer foot for the shared +rear bolt stack. The front rests on the panel; the rear rests on the outer fan foot. This clears +the rail flange by 1 mm. Guard pads enclose the four fan fastener holes; upper 7 mm / lower 2 mm +spacers account for the 5 mm fan foot. Guard protection, fan bolt length and engagement still need +assembly review. All 56 distinct modeled fastener axes have a coaxial mate in another printed part. +This is geometric alignment evidence, not a fastener strength or engagement test. + +Reproduction (from this worktree; use a memory-limited scope for the compiler): + +```sh +systemd-run --user --scope -p MemoryMax=24G -p MemorySwapMax=0 gunbc run \ + --source-root dag --source-root src/v2 \ + --entry dag/gunbc/product/printed_chassis/cassette_review.dag --function main > model.json +systemd-run --user --scope -p MemoryMax=24G -p MemorySwapMax=0 gunbc run \ + --source-root dag --source-root src/v2 \ + --entry dag/gunbc/product/printed_chassis/review_realization.dag --function main > realize-review.py +python realize-review.py model.json fresh-output-directory +``` + +Use the exercised CadQuery 2.8.0 environment. Output includes bed-oriented STL for each printed +instance, local-coordinate STEP, cassette assembly STEP, viewer meshes, a manifest with digests, +parts/print-quantity/interface CSVs and one review ZIP. Generated artifacts and run logs are retained +under `/home/briansrls/print-prep-2026-10-04/cassette-r05`; the Site carries the review package. +The adapter refuses invalid/disconnected printed solids, cuts missing material, out-of-bed parts, +nonmanifold STL, STEP/STL bounds or volume divergence, unmatched fastener bores, orphan interface +members and intersections between printed parts. Kernel bounding boxes explicitly ignore cached +triangulation, while STL bounds allow the modeled tessellation deflection. + +Remaining interfaces are deliberately represented as unresolved: board support thread/height and +underside keep-outs, loaded rail/joint/material behavior, real cooler pose and rear cable access, +fastener engagement, selected power/distribution hardware, thermal performance and unspecified +storage/expansion. No invented standoffs are provided. The first cassette is an unpowered assembly +fit, not a populated operational chassis. Review the complete parts and these interfaces together; +do not restart a gauge-by-gauge process. + +Final exercised generation passed for all 49 printed pieces, 29 unique geometries, 56 matched +fastener axes and zero printed-part intersections. Negative controls separately refused an oversized +part, a disconnected solid, an unknown primitive, a displaced mating bore and an orphan interface. +The emitted adapter was the code actually executed. Local browser checks passed with the final +package; the Site shows the same generated mesh geometry as the downloadable CAD. No slicing, +approval request or printer start was performed in this review step. Reuse the already fitted fan +plate when scheduling quantities after review. + +Review 05 published successfully at the existing private workbench, Site source `a9658fd05d27e83220c611b5fb00ac4397b74739`. diff --git a/sites/README.md b/sites/README.md index b417f2d5c6f..aa2322a3c1f 100644 --- a/sites/README.md +++ b/sites/README.md @@ -1,11 +1,36 @@ -# Chassis concept site +# Chassis workbench -[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site) is the private interactive design surface for iterating on the Altra carrier and rack. Site project: `appgprj_6ac187f2fe208191b1ec7ad6703d93e8`. +[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site) is the private +assembly review site. Reuse project `appgprj_6ac187f2fe208191b1ec7ad6703d93e8` and the separate +Sites-managed checkout at `sites/chassis-workbench/`; do not create a second site. -The separate Site checkout is `sites/chassis-workbench/` in the printing worktree. Its source is committed to the Sites-managed repository, first published commit `44ea76b0914cf99f6e80e94fee26554544b72cfe`. Reopen that same project for edits rather than creating another site. +Review 05 consumes meshes and metadata generated from `product.printed_chassis.cassette_review`. +Mechanical geometry, placements, print orientation and interfaces belong to `.dag`. The browser +only displays those meshes; the former `concept.js` and mechanical sliders are removed. View +controls change the camera, visibility, explosion and cassette withdrawal, never printable geometry. -Concept 04 source: `c2a49273da70f20a23e2e044fe552dceb74b773f`. The page provides 29 selectable parts/reference objects in rack view (25 on a cassette) at the default three-fan setting, isolated orbit inspection, exploded assembly, a conceptual 2×2 fixed frame, cassette withdrawal, design sliders and copyable settings. No persistent user data, credentials, printer controls or external application access are included. +- Carrier: all 49 printed instances, purchased hardware and labeled clearance reservations. +- All pieces: 29 unique authored print geometries with required quantities; isolate and rotate any. +- Rack: four cassettes in a fixed metal frame, fixed runners and one shared-power reservation. +- Downloads: individual STL/STEP, assembly STEP, logical manifest and a complete review ZIP with + parts, print quantities and interface hardware lists. +- Assembly review: the passed fan fit, proposed joints and remaining hardware decisions. -This is visualization geometry, not a manufacturing model. The accepted requirements remain in `docs/plans/printed-chassis-program.md`. Dynatron W1 is the target cooler and its published active envelope is depicted; installed offset/orientation remain unresolved. Concept 03 uses one fixed-frame shared 12 V supply system per four-node block, with four protected/isolatable node feeds. PSU capacity and distribution hardware remain unselected; the former per-node 400–600 W example is superseded. Actual standoff, underside, accepted PSU interfaces and load inputs remain unresolved; proposed geometry does not establish their dimensions. Before printing structural parts, selected geometry must move through the repository's modeled CAD and admission workflow. +The tray is 271.840 × 294.700 mm, split for the 180 mm A1 mini. The custom frame uses 140 mm bay +pitch; it is not a standard rack-unit compliance claim. Dynatron W1 and P8 envelopes come from +vendor authority. One fan plate has already physically fit; reuse it when counting the next prints. +Shared 12 V power remains fixed per four-node block with four isolatable/protected feeds. Capacity, +branch circuitry, connector choice and redundancy remain unresolved. Board supports are explicitly +unresolved rather than invented geometry. See `docs/plans/printed-chassis-program.md` for evidence, +reproduction, first-assembly scope and the existing fabrication/printing workflow. -Concept 04 moves fans to rear exhaust and provides the DAG-generated 92 × 92 × 4 mm fan plate as STL/STEP downloads. The plate is an unpowered fit prototype; cassette attachment and a finger guard remain unresolved. +CadQuery validation covers all printed solids, STL manifoldness/bounds/volume, STEP round-trip, +180 mm bed fit, 56 matched fastener axes and zero positive-volume intersections between printed +parts. It does not qualify strength, engagement, thermals or electrical operation. Local browser +checks cover generated mesh loading, roster, selection/isolation/keyboard rotation, rack extraction, +all-pieces view, filters, hardware/clearance visibility, downloads, interface table and mobile layout. +There are no printer controls, credentials, external application access or persistent user data. + +Earlier visual-only source: concept 04 `c2a49273da70f20a23e2e044fe552dceb74b773f`. + +Published review 05 Site source: `a9658fd05d27e83220c611b5fb00ac4397b74739` (owner-private audience unchanged). From 08506e8c8bce9d3cf8d0e287a87760ee9c6f1f7d Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 04:31:06 +0000 Subject: [PATCH 18/24] Route approval clients to the active store writer --- dag/gunbc/auth/approval_request_client.dag | 24 ++++++++++++++----- .../approval_request_client_witness_test.dag | 13 +++++++--- src/v2/workflow/required_floor.dag | 1 + 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/dag/gunbc/auth/approval_request_client.dag b/dag/gunbc/auth/approval_request_client.dag index f7aceb0336d..d3d0e253065 100644 --- a/dag/gunbc/auth/approval_request_client.dag +++ b/dag/gunbc/auth/approval_request_client.dag @@ -1,5 +1,8 @@ module gunbc.auth.approval_request_client +import gunbc.auth.approval_cutover_posture { ApprovalCutoverPosture, approval_cutover_posture, RoadmapServesAndWrites, BrokerServesAndWrites, BrokerOwnsAlone } +import gunbc.auth.approval_broker_endpoint { approval_broker_loopback_base_url, approval_broker_listen_port, approval_broker_submit_path } + import std.types { Bool, NonEmptyStr, String, Timestamp, Secret } import std.scoped_authorization { AuthorizationRequest } import extdeps.crypto.mac { @@ -18,14 +21,23 @@ import gunbc.auth.approval_request_submission { import gunbc.fleet_posix_accounts { fleet_operator_email } import gunbc.roadmap_dashboard_instance { srv1_live_dashboard_instance } -data approval_request_submit_path: NonEmptyStr = "/approvals" +data approval_request_submit_path: NonEmptyStr = approval_broker_submit_path + +// Local submissions must follow the same cutover posture as writer authority. The +// roadmap endpoint deliberately refuses store writes after the broker takes ownership. +fn approval_loopback_origin_for(posture: ApprovalCutoverPosture) -> NonEmptyStr { + match posture { + RoadmapServesAndWrites => { + let instance = srv1_live_dashboard_instance() + concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) as NonEmptyStr + } + BrokerServesAndWrites => approval_broker_loopback_base_url(port: approval_broker_listen_port) + BrokerOwnsAlone => approval_broker_loopback_base_url(port: approval_broker_listen_port) + } +} fn approval_loopback_origin() -> NonEmptyStr { - let instance = srv1_live_dashboard_instance() - concat( - concat("http://", instance.listen_host as String), - concat(":", to_string(instance.listen_port)), - ) as NonEmptyStr + approval_loopback_origin_for(posture: approval_cutover_posture) } fn approval_submit_url() -> NonEmptyStr { diff --git a/dag/test/claim/approval_request_client_witness_test.dag b/dag/test/claim/approval_request_client_witness_test.dag index 721a546e382..258d4160bb6 100644 --- a/dag/test/claim/approval_request_client_witness_test.dag +++ b/dag/test/claim/approval_request_client_witness_test.dag @@ -1,9 +1,11 @@ module test.claim.approval_request_client_witness_test +import gunbc.auth.approval_cutover_posture { approval_cutover_posture, RoadmapServesAndWrites, BrokerServesAndWrites, BrokerOwnsAlone } +import gunbc.auth.approval_broker_endpoint { approval_broker_loopback_base_url, approval_broker_listen_port } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } import std.types { Bool, NonEmptyStr, String } import gunbc.auth.approval_request_client { - approval_submit_url, approval_status_url, approval_loopback_origin, approval_request_submit_path, + approval_submit_url, approval_status_url, approval_loopback_origin, approval_loopback_origin_for, approval_request_submit_path, approval_poll_standing, PollStillPending, PollApproved, PollExpired, PollUnreadable, StoredApprovalRequest, } @@ -32,10 +34,15 @@ fn fixture_stored() -> StoredApprovalRequest { } } -test fn submit_and_status_urls_follow_the_srv1_instance_listen_bind() -> Bool { +test fn submit_and_status_urls_follow_the_writer_cutover() -> Bool { let instance = srv1_live_dashboard_instance() let origin = approval_loopback_origin() as String - origin == concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + let roadmap = concat(concat("http://", instance.listen_host as String), concat(":", to_string(instance.listen_port))) + let broker = approval_broker_loopback_base_url(port: approval_broker_listen_port) + approval_loopback_origin_for(posture: RoadmapServesAndWrites) as String == roadmap + && approval_loopback_origin_for(posture: BrokerServesAndWrites) == broker + && approval_loopback_origin_for(posture: BrokerOwnsAlone) == broker + && origin == (approval_loopback_origin_for(posture: approval_cutover_posture) as String) && approval_submit_url() as String == concat(origin, approval_request_submit_path as String) && approval_status_url(escalation_id: "esc-1") as String == concat(concat(origin, concat(approval_request_submit_path as String, "/")), "esc-1") } diff --git a/src/v2/workflow/required_floor.dag b/src/v2/workflow/required_floor.dag index 4493cbe223e..11b028f3301 100644 --- a/src/v2/workflow/required_floor.dag +++ b/src/v2/workflow/required_floor.dag @@ -737,6 +737,7 @@ fn fixture_home_prefixes() -> List { // "test.claim.if_join_pool_binding_witness_test", "test.claim.runner_slot_provision". fn required_gate_prefixes() -> List { [ + "test.claim.approval_request_client_witness", "test.claim.infer_", "test.claim.occurrence_binding_", "test.claim.declared_type_", From 4f0cfd773650e72bcaf4709895267c81fd475885 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 04:43:44 +0000 Subject: [PATCH 19/24] Separate cassette CAD and fit artifacts from the printer path --- dag/extdeps/cooling/dynatron.dag | 7 +- dag/extdeps/fans/arctic.dag | 5 +- .../printed_chassis/assembly_geometry.dag | 46 ---- .../product/printed_chassis/cassette.dag | 197 ------------------ .../printed_chassis/cassette_review.dag | 47 ----- .../product/printed_chassis/fan_mount.dag | 4 +- .../printed_chassis/fan_mount_print.dag | 31 --- .../product/printed_chassis/fit_gauges.dag | 99 --------- .../product/printed_chassis/rack_assembly.dag | 45 ---- .../printed_chassis/review_realization.dag | 195 ----------------- dag/test/claim/fit_gauges_witness_test.dag | 35 ---- .../asrock_rack/ALTRAD8UD_standoff_grid.png | Bin 156177 -> 0 bytes sites/.gitignore | 2 - sites/README.md | 36 ---- 14 files changed, 4 insertions(+), 745 deletions(-) delete mode 100644 dag/gunbc/product/printed_chassis/assembly_geometry.dag delete mode 100644 dag/gunbc/product/printed_chassis/cassette.dag delete mode 100644 dag/gunbc/product/printed_chassis/cassette_review.dag delete mode 100644 dag/gunbc/product/printed_chassis/fan_mount_print.dag delete mode 100644 dag/gunbc/product/printed_chassis/fit_gauges.dag delete mode 100644 dag/gunbc/product/printed_chassis/rack_assembly.dag delete mode 100644 dag/gunbc/product/printed_chassis/review_realization.dag delete mode 100644 dag/test/claim/fit_gauges_witness_test.dag delete mode 100644 docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png delete mode 100644 sites/.gitignore delete mode 100644 sites/README.md diff --git a/dag/extdeps/cooling/dynatron.dag b/dag/extdeps/cooling/dynatron.dag index d4865bf3c2d..35df435c133 100644 --- a/dag/extdeps/cooling/dynatron.dag +++ b/dag/extdeps/cooling/dynatron.dag @@ -1,7 +1,7 @@ module extdeps.cooling.dynatron import std.types { NonEmptyStr, List } -import std.measure { Micrometer, micrometer, watt, rack_unit } +import std.measure { watt, rack_unit } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.cooling.types { CpuCoolerCatalogRow } import extdeps.cpu.types { @@ -40,8 +40,3 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { first_citation: extdeps_external_authority_anchor, further_citations: [] } - -// Manufacturer active W1 envelope; installation pose belongs to the product assembly. -data dynatron_w1_active_length: Micrometer = micrometer(count: 141100) -data dynatron_w1_active_width: Micrometer = micrometer(count: 84000) -data dynatron_w1_active_height: Micrometer = micrometer(count: 72000) diff --git a/dag/extdeps/fans/arctic.dag b/dag/extdeps/fans/arctic.dag index f29a9a4403b..d028bc5fc18 100644 --- a/dag/extdeps/fans/arctic.dag +++ b/dag/extdeps/fans/arctic.dag @@ -1,7 +1,7 @@ module extdeps.fans.arctic import std.types { List } -import std.measure { Micrometer, micrometer, millimeter, percent, rpm } +import std.measure { micrometer, millimeter, percent, rpm } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.fans.types { FanMountingPattern, @@ -106,6 +106,3 @@ data arctic_p8_mounting_pattern: FanMountingPattern = fan_mounting_pattern( convention: "80 mm axial fan square mounting pattern, unverified against a held part", }, ) - -// Published axial frame depth, separate from the conventional mounting-hole pattern. -data arctic_p8_frame_depth: Micrometer = micrometer(count: 25000) diff --git a/dag/gunbc/product/printed_chassis/assembly_geometry.dag b/dag/gunbc/product/printed_chassis/assembly_geometry.dag deleted file mode 100644 index d094e9f258e..00000000000 --- a/dag/gunbc/product/printed_chassis/assembly_geometry.dag +++ /dev/null @@ -1,46 +0,0 @@ -module product.printed_chassis.assembly_geometry - -import std.types { Int, String, List, Bool } -import std.measure { Micrometer, micrometer } -import std.affine_space { Point3, point3 } -import std.coordinate_frame { coordinate_frame_identity } -import std.euclidean_geometry { Axis3 } -import product.printed_chassis.coupon { PlateDimensions, plate_dimensions } - -// Review geometry for the first assembled cassette. It is NOT AdmittedRealizationV0 and -// cannot enter the coupon's fabrication admission through a cast. The generic CAD adapter -// must validate positive primitives, solid connectivity, output extents and A1-mini fit. -// This explicit review rung replaces the former Three.js-only geometric authority. -// Coordinates use existing affine points; quantum is one micrometre, X across rear I/O, -// Y up, Z from rear toward the handle. Part-local frame 5100; assembly frame 5101. -type Primitive = Block { size: PlateDimensions, center: Point3 } - | RoundPrism { diameter: Micrometer, length: Micrometer, axis: Axis3, center: Point3 } -type PartKind = Printed | Purchased | Metal | Clearance -// Orientation of the part's assembly-local coordinates when laid on a print bed. -type PrintPlane = LayOnXZ | LayOnXY | LayOnYZ -// Scope prevents rack power equipment being cloned into each moving cassette. -type PartScope = Cassette | FixedFrame - -type AssemblyPart { - id: String, name: String, kind: PartKind, scope: PartScope, - adds: List, cuts: List, at: Point3, explode: Point3, - print_plane: PrintPlane, notes: String, -} -type MechanicalInterface { - id: String, members: List, fastener: String, quantity: Int, - status: String, requirement: String, -} -fn local(x: Int, y: Int, z: Int) -> Point3 { point3(frame: coordinate_frame_identity(occurrence: 5100), x: x, y: y, z: z) } -fn placed(x: Int, y: Int, z: Int) -> Point3 { point3(frame: coordinate_frame_identity(occurrence: 5101), x: x, y: y, z: z) } -fn block(w: Int, h: Int, d: Int, x: Int, y: Int, z: Int) -> Primitive { - Block { size: plate_dimensions(size_x: micrometer(count: w), size_y: micrometer(count: h), size_z: micrometer(count: d)), center: local(x: x,y: y,z: z) } -} -fn round(diameter: Int, length: Int, axis: Axis3, x: Int, y: Int, z: Int) -> Primitive { - RoundPrism { diameter: micrometer(count: diameter), length: micrometer(count: length), axis: axis, center: local(x: x,y: y,z: z) } -} -fn part(id: String, name: String, kind: PartKind, scope: PartScope, adds: List, cuts: List, x: Int,y: Int,z: Int, ex: Int,ey: Int,ez: Int, plane: PrintPlane, notes: String) -> AssemblyPart { - AssemblyPart { id: id,name: name,kind: kind,scope: scope,adds: adds,cuts: cuts,at: placed(x: x,y: y,z: z),explode: placed(x: ex,y: ey,z: ez),print_plane: plane,notes: notes } -} -fn mechanical_interface(id: String,members: List,fastener: String,quantity: Int,status: String,requirement: String) -> MechanicalInterface { - MechanicalInterface { id: id,members: members,fastener: fastener,quantity: quantity,status: status,requirement: requirement } -} diff --git a/dag/gunbc/product/printed_chassis/cassette.dag b/dag/gunbc/product/printed_chassis/cassette.dag deleted file mode 100644 index 44be37c6031..00000000000 --- a/dag/gunbc/product/printed_chassis/cassette.dag +++ /dev/null @@ -1,197 +0,0 @@ -module product.printed_chassis.cassette - -import std.types { Bool, Int, String, List } -import std.measure { micrometer_count } -import std.euclidean_geometry { Axis3X, Axis3Y, Axis3Z } -import product.printed_chassis.assembly_geometry { - Primitive, AssemblyPart, MechanicalInterface, Printed, Purchased, Metal, Clearance, - Cassette, FixedFrame, LayOnXZ, LayOnXY, LayOnYZ, block, round, part, mechanical_interface, -} -import extdeps.boards.asrock_rack { asrock_altrad8ud_extent_along_rear_io, asrock_altrad8ud_extent_depth } -import product.printed_chassis.fan_mount { - arctic_p8_fan_mount, FanMountAdmitted, fan_mount_plate, fan_mount_bore, fan_mount_mounting_holes, -} -import product.printed_chassis.coupon { OpThroughHole } -import extdeps.fans.arctic { arctic_p8_mounting_pattern } - -// This root owns the entire review assembly formerly authored in the browser. -// Nominal board lengths come from the board authority; design allowances below are proposals. -// No measured board thickness, standoff thread, PSU rating or thermal result is invented. -data board_w: Int = micrometer_count(m: asrock_altrad8ud_extent_along_rear_io) -data board_d: Int = micrometer_count(m: asrock_altrad8ud_extent_depth) -data margin: Int = 14000 -data tray_w: Int = board_w + 2 * margin -data tray_d: Int = board_d + 2 * margin -data panel_t: Int = 5000 -data seam: Int = 2000 -data panel_w: Int = (tray_w - seam) / 2 -data panel_d: Int = (tray_d - seam) / 2 -data bay_pitch: Int = 140000 -data bay_w: Int = tray_w + 48000 -data rear_plate_z: Int = 0 - tray_d / 2 - 76000 -data rear_beam_z: Int = rear_plate_z + 20000 -data fan_spacing: Int = 94000 -data fan_hole_offset: Int = micrometer_count(m: arctic_p8_mounting_pattern.hole_pitch) / 2 -data fan_hole_d: Int = micrometer_count(m: arctic_p8_mounting_pattern.hole_clearance_diameter) -data m3_clearance: Int = 3400 -// Fan fit reported by operator 2026-10-04: physical plate/fan fit passed. Does not -// rewrite the upstream ConventionUnverified row into a dimensional measurement. -data fan_fit_observation: String = "Operator reported the printed rear fan plate fits the fan, 2026-10-04. No additional gauge required." -fn bolt_y(x: Int,z: Int) -> Primitive { round(diameter: m3_clearance,length: 40000,axis: Axis3Y,x: x,y: 0,z: z) } - -fn panel(sx: Int,sz: Int,id: String) -> AssemblyPart { - let cx = sx * (tray_w + seam) / 4 - let cz = sz * (tray_d + seam) / 4 - let slot_w = (panel_w - 52000) / 4 - let vents = fold([0,1,2,3],init: [],f: fn(acc,i) { - let x = 0 - panel_w / 2 + 26000 + slot_w / 2 + i * slot_w - let width = slot_w - 4000 - let half_run = (panel_d - 56000 - width) / 2 - concat(acc,[block(w: width,h: 9000,d: half_run*2,x: x,y: 2500,z: 0), - round(diameter: width,length: 9000,axis: Axis3Y,x: x,y: 2500,z: half_run), - round(diameter: width,length: 9000,axis: Axis3Y,x: x,y: 2500,z: 0-half_run)]) - }) - // Seam holes sit in the perimeter rim. Bridges below share these exact global centres. - let seam_x = fold([-5000,5000],init: [],f: fn(acc,dz) { concat(acc,[bolt_y(x: sx*12000-cx,z: dz)]) }) - let seam_z = fold([-5000,5000],init: [],f: fn(acc,dx) { concat(acc,[bolt_y(x: dx,z: sz*12000-cz)]) }) - let rail = fold([-1,1],init: [],f: fn(acc,j) { concat(acc,[bolt_y(x: sx*(tray_w/2-7000)-cx,z: j*(panel_d/2-18000))]) }) - let handle = if sz==1 { [bolt_y(x: sx*50000-cx,z: tray_d/2-10000-cz)] } else { [] } - let extension = if sz== -1 { [bolt_y(x: sx*(tray_w/2-7000)-cx,z: 0-tray_d/2+20000-cz)] } else { [] } - let retainer = if sz==1 { [bolt_y(x: sx*(tray_w/2-22000)-cx,z: tray_d/2-18000-cz)] } else { [] } - part(id: id,name: concat("Carrier panel ",id),kind: Printed,scope: Cassette, - adds: [block(w: panel_w,h: panel_t,d: panel_d,x: 0,y: panel_t/2,z: 0)], - cuts: concat(vents,concat(seam_x,concat(seam_z,concat(rail,concat(handle,concat(extension,retainer)))))), - x: cx,y: 0,z: cz,ex: sx*65000,ey: 12000,ez: sz*55000,plane: LayOnXZ, - notes: "Four vented panels; M3 through-bolted seams and rail interfaces. Board supports remain a separate unresolved interface; do not place a powered board directly on this tray.") -} -fn panel_bridge(sx: Int,sz: Int,id: String) -> AssemblyPart { - let across_x = sx==0 - let w = if across_x { 42000 } else { 20000 } - let d = if across_x { 20000 } else { 42000 } - let holes = fold([-1,1],init: [],f: fn(acc,a) { concat(acc,fold([-1,1],init: [],f: fn(inner,b) { concat(inner,[bolt_y(x: a*(if across_x {12000} else {5000}),z: b*(if across_x {5000} else {12000}))]) })) }) - part(id: id,name: "Panel seam splice",kind: Printed,scope: Cassette,adds:[block(w:w,h:5000,d:d,x:0,y:0,z:0)],cuts:holes, - x:sx*(tray_w+seam)/4,y: -2500,z:sz*(tray_d+seam)/4,ex:sx*40000,ey: -45000,ez:sz*40000,plane:LayOnXZ,notes:"Four M3 through-bolts; joint geometry proposal, unpowered assembly fit first.") -} -fn rail(sx: Int,sz: Int,id: String) -> AssemblyPart { - let cx=sx*(tray_w/2-2000) - let cz=sz*(tray_d+seam)/4 - let holes=concat(fold([-1,1],init:[],f:fn(acc,j){concat(acc,[bolt_y(x:0-sx*5000,z:j*(panel_d/2-18000))])}), - [bolt_y(x:0-sx*5000,z:sz*12000-cz)]) - let cuts=if sz== -1 {concat(holes,[bolt_y(x:0-sx*5000,z:0-tray_d/2+20000-cz)])} else {holes} - part(id:id,name:"Segmented carrier rail",kind:Printed,scope:Cassette, - adds:[block(w:24000,h:4000,d:panel_d,x:0,y: -2000,z:0),block(w:4000,h:16000,d:panel_d,x:sx*10000,y:4000,z:0)],cuts:cuts, - x:cx,y:0,z:cz,ex:sx*85000,ey: -20000,ez:sz*30000,plane:LayOnXZ,notes:"L section; overlaps panel edge by 14 mm. M3 bolts to panel and separate rail splice. Sliding clearance/load qualification remains open.") -} -fn rail_bridge(sx: Int,id: String) -> AssemblyPart { - part(id:id,name:"Rail seam splice",kind:Printed,scope:Cassette,adds:[block(w:18000,h:4000,d:42000,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z: -12000),bolt_y(x:0,z:12000)], - x:sx*(tray_w/2-7000),y: -6000,z:0,ex:sx*90000,ey: -45000,ez:0,plane:LayOnXZ,notes:"Two M3 through-bolts across the rail seam; backing nuts/washers remain accessible below.") -} -fn handle() -> AssemblyPart { - part(id:"H1",name:"Front pull handle",kind:Printed,scope:Cassette, - adds:[block(w:110000,h:10000,d:12000,x:0,y:0,z:30000),block(w:18000,h:10000,d:48000,x: -46000,y:0,z:12000),block(w:18000,h:10000,d:48000,x:46000,y:0,z:12000)], - cuts:[bolt_y(x: -50000,z:0),bolt_y(x:50000,z:0)],x:0,y:10000,z:tray_d/2-10000,ex:0,ey:15000,ez:110000,plane:LayOnXZ,notes:"M3 through-bolts into both front panels. Pull/load test before carrying a populated node; front kept clear of chassis fans.") -} -fn retaining_tab(sx: Int,id: String) -> AssemblyPart { - part(id:id,name:"Front retaining stop",kind:Printed,scope:Cassette, - adds:[block(w:24000,h:5000,d:36000,x:0,y:0,z:0),block(w:6000,h:12000,d:9000,x:sx*9000,y:6000,z:13000)],cuts:[bolt_y(x:0,z: -8000)], - x:sx*(tray_w/2-22000),y:7500,z:tray_d/2-10000,ex:sx*50000,ey:40000,ez:55000,plane:LayOnXZ,notes:"Rigid stop with pivot bolt, not a snap latch. Engagement with the metal rack still needs review.") -} -fn extension(sx: Int,id: String) -> AssemblyPart { - part(id:id,name:"Rear fan-bank extension",kind:Printed,scope:Cassette, - adds:[block(w:20000,h:8000,d:112000,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z:30000),bolt_y(x:0,z: -46000)], - x:sx*(tray_w/2-7000),y: -8000,z:0-tray_d/2-10000,ex:sx*55000,ey: -45000,ez: -50000,plane:LayOnXZ, - notes:"Connects the rear panel/rail stack to the fan crossmember. Cantilever stiffness and creep unqualified.") -} -fn fan_beam(sx: Int,id: String) -> AssemblyPart { - let width=2*fan_spacing+92000 - let cx=sx*(width+seam)/4 - let w=(width-seam)/2 - let feet=fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,fold([-1,1],init:[],f:fn(inner,j){ - let x=i*fan_spacing+j*25000 - if x*sx>0 {concat(inner,[bolt_y(x:x-cx,z:0)])} else {inner} - }))}) - part(id:id,name:"Rear fan crossmember half",kind:Printed,scope:Cassette, - adds:[block(w:w,h:8000,d:24000,x:0,y:0,z:0)],cuts:concat(feet,[bolt_y(x:sx*12000-cx,z:0),bolt_y(x:sx*(tray_w/2-7000)-cx,z:0)]), - x:cx,y:0,z:rear_beam_z,ex:sx*50000,ey: -10000,ez: -85000,plane:LayOnXZ,notes:"Three fan feet bolt to this split crossmember. End holes align with rear extension arms; centre splice bridges the print split.") -} -fn fan_beam_bridge() -> AssemblyPart { - part(id:"FBJ",name:"Fan crossmember splice",kind:Printed,scope:Cassette,adds:[block(w:42000,h:4000,d:20000,x:0,y:0,z:0)],cuts:[bolt_y(x: -12000,z:0),bolt_y(x:12000,z:0)], - x:0,y: -6000,z:rear_beam_z,ex:0,ey: -50000,ez: -85000,plane:LayOnXZ,notes:"Two M3 bolts join the rear crossmember halves.") -} -fn fan_plate(i: Int,id: String) -> AssemblyPart { - // Read the sealed geometry used by the successfully fitted print. No copied pattern. - let adds=match arctic_p8_fan_mount { FanMountAdmitted {geometry:g} => { - let p=fan_mount_plate(g:g) - [block(w:micrometer_count(m:p.size_x),h:micrometer_count(m:p.size_y),d:micrometer_count(m:p.size_z),x:0,y:0,z:0)] - } _ => [] } - let cuts=match arctic_p8_fan_mount { FanMountAdmitted {geometry:g} => { - let p=fan_mount_plate(g:g) - concat([fan_mount_bore(g:g)],fan_mount_mounting_holes(g:g)) |> map(op => match op { - OpThroughHole {diameter:diam,center_x:x,center_y:y} => round(diameter:micrometer_count(m:diam),length:10000,axis:Axis3Z, - x:micrometer_count(m:x)-micrometer_count(m:p.size_x)/2,y:micrometer_count(m:y)-micrometer_count(m:p.size_y)/2,z:0) - }) - } _ => [] } - part(id:id,name:"Rear fan mounting plate",kind:Printed,scope:Cassette,adds:adds,cuts:cuts, - x:i*fan_spacing,y:55000,z:rear_plate_z,ex:i*40000,ey:0,ez: -60000,plane:LayOnXY,notes:fan_fit_observation) -} -fn fan_foot(i: Int,id: String) -> AssemblyPart { - part(id:id,name:"Rear fan plate foot",kind:Printed,scope:Cassette, - adds:[block(w:92000,h:5000,d:35000,x:0,y:6500,z:10500),block(w:92000,h:25000,d:5000,x:0,y:16500,z: -4500)], - cuts:concat(if i==0 {[]} else {[bolt_y(x:i*(tray_w/2-7000-fan_spacing),z:20000)]},[bolt_y(x: -25000,z:20000),bolt_y(x:25000,z:20000),round(diameter:fan_hole_d,length:12000,axis:Axis3Z,x: -fan_hole_offset,y:55000-fan_hole_offset,z: -4500),round(diameter:fan_hole_d,length:12000,axis:Axis3Z,x:fan_hole_offset,y:55000-fan_hole_offset,z: -4500)]), - x:i*fan_spacing,y:0,z:rear_plate_z,ex:i*40000,ey: -20000,ez: -60000,plane:LayOnXZ,notes:"L bracket: two lower fan bolts and two M3 crossmember bolts. Printed flat on its base; vertical wall has no unsupported overhang.") -} -fn fan_guard(i: Int,id: String) -> AssemblyPart { - let rim=[block(w:92000,h:8000,d:3000,x:0,y:42000,z:0),block(w:92000,h:8000,d:3000,x:0,y: -42000,z:0),block(w:8000,h:92000,d:3000,x:42000,y:0,z:0),block(w:8000,h:92000,d:3000,x: -42000,y:0,z:0)] - let pads=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,y){concat(inner,[block(w:12000,h:12000,d:3000,x:x*fan_hole_offset,y:y*fan_hole_offset,z:0)])}))}) - let bars=fold([-3,-2,-1,0,1,2,3],init:[],f:fn(acc,j){concat(acc,[block(w:84000,h:4000,d:3000,x:0,y:j*11000,z:0)])}) - let holes=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,y){concat(inner,[round(diameter:fan_hole_d,length:8000,axis:Axis3Z,x:x*fan_hole_offset,y:y*fan_hole_offset,z:0)])}))}) - part(id:id,name:"Rear fan guard",kind:Printed,scope:Cassette,adds:concat(rim,concat(pads,bars)),cuts:holes, - x:i*fan_spacing,y:55000,z:rear_plate_z-10500,ex:i*40000,ey:0,ez: -105000,plane:LayOnXY,notes:"7 mm nominal grille gaps, provisional guard geometry; not a certified finger-protection rating. Upper 7 mm and lower 2 mm spacers clear the 5 mm foot behind the plate.") -} -fn guard_spacer(i:Int,sx:Int,sy:Int,id:String) -> AssemblyPart { - let len=if sy==1 {7000} else {2000} - part(id:id,name:"Fan guard spacer",kind:Printed,scope:Cassette, - adds:[round(diameter:8000,length:len,axis:Axis3Z,x:0,y:0,z:0)],cuts:[round(diameter:fan_hole_d,length:len+2000,axis:Axis3Z,x:0,y:0,z:0)], - x:i*fan_spacing+sx*fan_hole_offset,y:55000+sy*fan_hole_offset,z:rear_plate_z-9000+len/2,ex:i*40000+sx*10000,ey:sy*12000,ez: -85000,plane:LayOnXY, - notes:"Spacing proposal for the guard/plate/foot fastener stack. Verify clearance and fastener engagement in complete assembly.") -} -fn guide(sx:Int,id:String) -> AssemblyPart { - let gx=fan_spacing+46000+3000 - let reach=gx-(tray_w/2-7000) - part(id:id,name:"Rear side airflow guide",kind:Printed,scope:Cassette, - adds:[block(w:3000,h:80000,d:120000,x:0,y:40000,z:0),block(w:reach+10000,h:4000,d:120000,x:0-sx*reach/2,y:2000,z:0)], - cuts:[bolt_y(x:0-sx*reach,z:30000),bolt_y(x:0-sx*reach,z: -46000)], - x:sx*gx,y:13000,z:0-tray_d/2-10000,ex:sx*75000,ey:20000,ez: -35000,plane:LayOnYZ, - notes:"L-section guide shares the extension stack. Front bolt passes an 8 mm spacer, panel, rail and extension; rear bolt passes a 4 mm spacer, outer fan foot, crossmember and extension. Guide clears rail flange by 1 mm. Air sealing/thermal effectiveness are unqualified.") -} -fn guide_shim(sx:Int,front:Bool,id:String) -> AssemblyPart { - let height=if front {8000} else {4000} - part(id:id,name:"Rear guide spacer",kind:Printed,scope:Cassette, - adds:[round(diameter:8000,length:height,axis:Axis3Y,x:0,y:0,z:0)],cuts:[bolt_y(x:0,z:0)], - x:sx*(tray_w/2-7000),y:13000-height/2,z:if front {0-tray_d/2+20000} else {rear_beam_z},ex:sx*75000,ey:15000,ez: -55000,plane:LayOnXZ,notes:"Derived guide support stack: front rests on panel, rear on outer fan foot. Both top faces at 13 mm, clearing rail flange.") -} -fn printed_parts() -> List { - concat([ - panel(sx: -1,sz: -1,id:"P1"),panel(sx:1,sz: -1,id:"P2"),panel(sx: -1,sz:1,id:"P3"),panel(sx:1,sz:1,id:"P4"), - panel_bridge(sx:0,sz: -1,id:"J1"),panel_bridge(sx:0,sz:1,id:"J2"),panel_bridge(sx: -1,sz:0,id:"J3"),panel_bridge(sx:1,sz:0,id:"J4"), - rail(sx: -1,sz: -1,id:"R1"),rail(sx: -1,sz:1,id:"R2"),rail(sx:1,sz: -1,id:"R3"),rail(sx:1,sz:1,id:"R4"),rail_bridge(sx: -1,id:"RJ1"),rail_bridge(sx:1,id:"RJ2"), - guide_shim(sx: -1,front:false,id:"DS1"),guide_shim(sx:1,front:false,id:"DS2"),guide_shim(sx: -1,front:true,id:"DS3"),guide_shim(sx:1,front:true,id:"DS4"),handle(),retaining_tab(sx: -1,id:"T1"),retaining_tab(sx:1,id:"T2"),extension(sx: -1,id:"E1"),extension(sx:1,id:"E2"),fan_beam(sx: -1,id:"FB1"),fan_beam(sx:1,id:"FB2"),fan_beam_bridge(), - fan_plate(i: -1,id:"M1"),fan_plate(i:0,id:"M2"),fan_plate(i:1,id:"M3"),fan_foot(i: -1,id:"MF1"),fan_foot(i:0,id:"MF2"),fan_foot(i:1,id:"MF3"), - fan_guard(i: -1,id:"G1"),fan_guard(i:0,id:"G2"),fan_guard(i:1,id:"G3"),guide(sx: -1,id:"D1"),guide(sx:1,id:"D2") - ],fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,fold([-1,1],init:[],f:fn(inner,x){concat(inner,fold([-1,1],init:[],f:fn(last,y){concat(last,[guard_spacer(i:i,sx:x,sy:y,id:join(["SP",to_string(i),to_string(x),to_string(y)],"_"))])}))}))})) -} - -fn interfaces() -> List { - [mechanical_interface(id:"panel-seams",members:["P1","P2","P3","P4","J1","J2","J3","J4"],fastener:"M3 bolt + washer + nut; length selected from stack",quantity:16,status:"proposed",requirement:"Matched 3.4 mm through-holes; panel seam gap 2 mm."), - mechanical_interface(id:"panel-rails",members:["P1","P2","P3","P4","R1","R2","R3","R4"],fastener:"M3 bolt + washer + nut",quantity:8,status:"proposed",requirement:"9 mm nominal plastic stack; no screw-tip contact with board."), - mechanical_interface(id:"rail-splices",members:["R1","R2","R3","R4","RJ1","RJ2"],fastener:"M3 bolt + washer + nut",quantity:4,status:"proposed",requirement:"Two bolts per splice; 8 mm stack."), - mechanical_interface(id:"handle",members:["H1","P3","P4"],fastener:"M3 bolt + washer + nut",quantity:2,status:"proposed",requirement:"15 mm stack; pull/load qualification pending."), - mechanical_interface(id:"retention",members:["T1","T2","P3","P4","FRAME"],fastener:"M3 pivot bolt + washer + locknut",quantity:2,status:"proposed",requirement:"Rack-stop engagement and extraction clearance to review."), - mechanical_interface(id:"rear-extensions",members:["E1","E2","R1","R3","P1","P2","D1","D2","DS1","DS2","DS3","DS4","MF1","MF3","FB1","FB2"],fastener:"M3 through-bolts + nuts; guide spacers",quantity:4,status:"proposed",requirement:"Front and rear shared stacks; verify guide spacers and accessible nuts."), - mechanical_interface(id:"fan-beam-splice",members:["FB1","FB2","FBJ"],fastener:"M3 bolt + washer + nut",quantity:2,status:"proposed",requirement:"12 mm stack."), - mechanical_interface(id:"fan-feet",members:["MF1","MF2","MF3","FB1","FB2"],fastener:"M3 bolt + washer + nut",quantity:6,status:"proposed",requirement:"13 mm stack, two bolts per fan foot."), - mechanical_interface(id:"fan-pattern",members:["F1","F2","F3","M1","M2","M3","MF1","MF2","MF3","G1","G2","G3"],fastener:"M4 through-bolts + nuts; 7 mm upper / 2 mm lower guard spacers",quantity:12,status:"plate fit passed; new stack proposed",requirement:"39 mm nominal stack before washers/nut; assess M4x45 engagement and actual fan fastener path."), - mechanical_interface(id:"board-support",members:["BOARD","P1","P2","P3","P4"],fastener:"Standoffs and adjustable support hardware not selected",quantity:0,status:"unresolved",requirement:"Known 1–3 and 3–4 alignment passes; no further gauges requested. Underside keep-outs/thread/height required before populated operation. No fabricated standoff geometry."), - mechanical_interface(id:"power",members:["PSU","PDB","DC","FEEDS"],fastener:"Four rated protected/isolatable 12 V branches",quantity:4,status:"unresolved",requirement:"Shared supply per block. No wattage, wire gauge, connector pinout or live hot-swap admission."), - mechanical_interface(id:"rack-ground",members:["FRAME","SHELF","PSU"],fastener:"Dedicated protective-earth bonding hardware",quantity:0,status:"unresolved",requirement:"Bond persists with every cassette removed; printed polymer is not the bond or mains enclosure.")] -} diff --git a/dag/gunbc/product/printed_chassis/cassette_review.dag b/dag/gunbc/product/printed_chassis/cassette_review.dag deleted file mode 100644 index 7f3f2cdf2a1..00000000000 --- a/dag/gunbc/product/printed_chassis/cassette_review.dag +++ /dev/null @@ -1,47 +0,0 @@ -module product.printed_chassis.cassette_review - -import std.types { Int, String, List } -import std.measure { micrometer_count, millimeter_count } -import std.affine_space { Point3, signed_frame_component_count } -import std.euclidean_geometry { Axis3X, Axis3Y, Axis3Z } -import extdeps.languages.json.emit { JsonValue,json_object,json_kv,json_string,json_int,json_array,json_bool,json_null,serialize_json } -import gunbc.cli_wire { CliWireResponse,CliWirePrintable } -import std.process { ExitSuccess } -import product.printed_chassis.assembly_geometry { Primitive,Block,RoundPrism,AssemblyPart,MechanicalInterface,Printed,Purchased,Metal,Clearance,Cassette,FixedFrame,LayOnXZ,LayOnXY,LayOnYZ } -import product.printed_chassis.cassette { printed_parts,interfaces,board_w,board_d,tray_w,tray_d,bay_w,bay_pitch,fan_fit_observation,m3_clearance,fan_hole_d } -import product.printed_chassis.rack_assembly { hardware,fixed_parts } -import product.printed_chassis.export_profile { exercised_stl_knobs } -import extdeps.printing.bambu_lab_a1_mini { a1_mini_build_envelope } -import extdeps.printing.fdm { build_envelope_x,build_envelope_y,build_envelope_z } - -fn xyz(p:Point3) -> JsonValue { json_array(elements:[json_int(n:signed_frame_component_count(c:p.x)),json_int(n:signed_frame_component_count(c:p.y)),json_int(n:signed_frame_component_count(c:p.z))]) } -fn text(s:String) -> JsonValue { json_string(s:s) } -fn primitive_json(p:Primitive) -> JsonValue { - match p { - Block {size:s,center:c} => json_object(members:[json_kv(key:"op",value:text(s:"box")),json_kv(key:"size_um",value:json_array(elements:[json_int(n:micrometer_count(m:s.size_x)),json_int(n:micrometer_count(m:s.size_y)),json_int(n:micrometer_count(m:s.size_z))])),json_kv(key:"center_um",value:xyz(p:c))]), - RoundPrism {diameter:d,length:l,axis:a,center:c} => json_object(members:[json_kv(key:"op",value:text(s:"cylinder")),json_kv(key:"diameter_um",value:json_int(n:micrometer_count(m:d))),json_kv(key:"length_um",value:json_int(n:micrometer_count(m:l))),json_kv(key:"axis",value:text(s:match a {Axis3X=>"x",Axis3Y=>"y",Axis3Z=>"z"})),json_kv(key:"center_um",value:xyz(p:c))]), - } -} -fn part_json(p:AssemblyPart) -> JsonValue { - json_object(members:[json_kv(key:"id",value:text(s:p.id)),json_kv(key:"name",value:text(s:p.name)),json_kv(key:"kind",value:text(s:match p.kind {Printed=>"printed",Purchased=>"purchased",Metal=>"metal",Clearance=>"clearance"})),json_kv(key:"scope",value:text(s:match p.scope {Cassette=>"cassette",FixedFrame=>"fixed"})),json_kv(key:"adds",value:json_array(elements:p.adds |> map(a=>primitive_json(p:a)))),json_kv(key:"cuts",value:json_array(elements:p.cuts |> map(a=>primitive_json(p:a)))),json_kv(key:"at_um",value:xyz(p:p.at)),json_kv(key:"explode_um",value:xyz(p:p.explode)),json_kv(key:"print_plane",value:text(s:match p.print_plane {LayOnXZ=>"XZ",LayOnXY=>"XY",LayOnYZ=>"YZ"})),json_kv(key:"notes",value:text(s:p.notes))]) -} -fn interface_json(i:MechanicalInterface) -> JsonValue { - json_object(members:[json_kv(key:"id",value:text(s:i.id)),json_kv(key:"members",value:json_array(elements:i.members |> map(x=>text(s:x)))),json_kv(key:"fastener",value:text(s:i.fastener)),json_kv(key:"quantity",value:json_int(n:i.quantity)),json_kv(key:"status",value:text(s:i.status)),json_kv(key:"requirement",value:text(s:i.requirement))]) -} -fn all_parts() -> List { concat(printed_parts(),concat(hardware(),fixed_parts())) } -fn vector(x:Int,y:Int,z:Int) -> JsonValue { json_array(elements:[json_int(n:x),json_int(n:y),json_int(n:z)]) } -fn node(id:String,x:Int,y:Int) -> JsonValue { json_object(members:[json_kv(key:"id",value:text(s:id)),json_kv(key:"at_um",value:vector(x:x,y:y,z:0))]) } -fn airflow(x:Int) -> JsonValue { json_object(members:[json_kv(key:"direction",value:vector(x:0,y:0,z:-1)),json_kv(key:"start_um",value:vector(x:x,y:105000,z:tray_d/2+55000)),json_kv(key:"length_um",value:json_int(n:tray_d+140000))]) } -fn manifest() -> JsonValue { - json_object(members:[json_kv(key:"schema",value:text(s:"gunbc.cassette-review.v1")),json_kv(key:"revision",value:text(s:"05")),json_kv(key:"quantum",value:text(s:"micrometre")),json_kv(key:"axes",value:text(s:"X across rear I/O; Y up; +Z toward front handle")),json_kv(key:"release",value:text(s:"review-only; no print or powered-operation admission")),json_kv(key:"fan_fit",value:text(s:fan_fit_observation)), - json_kv(key:"board_um",value:json_array(elements:[json_int(n:board_w),json_int(n:board_d)])),json_kv(key:"tray_um",value:json_array(elements:[json_int(n:tray_w),json_int(n:tray_d)])),json_kv(key:"bay_pitch_um",value:json_int(n:bay_pitch)),json_kv(key:"bay_width_um",value:json_int(n:bay_w)),json_kv(key:"nodes_per_block",value:json_int(n:4)),json_kv(key:"intended_nodes",value:json_int(n:8)), - json_kv(key:"fastener_clearance_um",value:json_array(elements:[json_int(n:m3_clearance),json_int(n:fan_hole_d)])), - json_kv(key:"nodes",value:json_array(elements:[node(id:"N1",x:-bay_w/2,y:0),node(id:"N2",x:bay_w/2,y:0),node(id:"N3",x:-bay_w/2,y:bay_pitch),node(id:"N4",x:bay_w/2,y:bay_pitch)])), - json_kv(key:"airflow",value:json_array(elements:[airflow(x:-75000),airflow(x:75000)])), - json_kv(key:"export",value:json_object(members:[json_kv(key:"linear_tolerance_um",value:json_int(n:micrometer_count(m:exercised_stl_knobs.linear_tolerance))),json_kv(key:"angular_tolerance_radians",value:text(s:exercised_stl_knobs.angular_tolerance_text as String))])), - json_kv(key:"power",value:json_object(members:[json_kv(key:"placement",value:text(s:"fixed-frame shared 12 V system per four-node block")),json_kv(key:"branches",value:json_int(n:4)),json_kv(key:"rated_watts",value:json_null()),json_kv(key:"live_hot_swap",value:json_bool(b:false)),json_kv(key:"redundancy",value:json_null())])), - json_kv(key:"print_envelope_mm",value:json_array(elements:[json_int(n:millimeter_count(m:build_envelope_x(e:a1_mini_build_envelope))),json_int(n:millimeter_count(m:build_envelope_y(e:a1_mini_build_envelope))),json_int(n:millimeter_count(m:build_envelope_z(e:a1_mini_build_envelope)))])), - json_kv(key:"parts",value:json_array(elements:all_parts() |> map(p=>part_json(p:p)))),json_kv(key:"interfaces",value:json_array(elements:interfaces() |> map(i=>interface_json(i:i)))), - json_kv(key:"unresolved",value:json_array(elements:[text(s:"Board standoff thread, installed height and underside keep-outs; no new gauge sequence."),text(s:"Fastener lengths/engagement, support material, rail clearance and loaded joint performance."),text(s:"Cooler location/orientation and rear I/O/cable service access."),text(s:"Shared supply selection, branch circuitry, protective earth and thermal validation."),text(s:"Drives and expansion cards are unspecified, not silently omitted requirements.")]))]) -} -fn main() -> CliWireResponse { CliWirePrintable {bytes:serialize_json(v:manifest()),exit:ExitSuccess} } diff --git a/dag/gunbc/product/printed_chassis/fan_mount.dag b/dag/gunbc/product/printed_chassis/fan_mount.dag index f3d8831638e..0eb4c4db37a 100644 --- a/dag/gunbc/product/printed_chassis/fan_mount.dag +++ b/dag/gunbc/product/printed_chassis/fan_mount.dag @@ -122,8 +122,8 @@ fn admit_fan_mount( } // THE FIRST PART TO PRINT. A 92 mm plate carries the 71.5 mm pattern with material to spare, 4 mm of -// thickness is an unqualified prototype choice, and the 76 mm aperture stays inside the -// screw-hole circle. Held-fan fit, stiffness, cassette attachment and guarding remain to be checked. +// thickness is stiff enough to hold a fan without warping on a textured plate, and a 76 mm bore clears the +// blades while staying well inside the 50.6 mm screw-hole circle. data arctic_p8_mount_plate_edge: Micrometer = micrometer(count: 92000) data arctic_p8_mount_plate_thickness: Micrometer = micrometer(count: 4000) data arctic_p8_mount_bore: Micrometer = micrometer(count: 76000) diff --git a/dag/gunbc/product/printed_chassis/fan_mount_print.dag b/dag/gunbc/product/printed_chassis/fan_mount_print.dag deleted file mode 100644 index 730a94b791c..00000000000 --- a/dag/gunbc/product/printed_chassis/fan_mount_print.dag +++ /dev/null @@ -1,31 +0,0 @@ -module product.printed_chassis.fan_mount_print - -import std.types { String } -import std.process { ExitSuccess, exit_failure } -import gunbc.cli_wire { CliWireResponse, CliWirePrintable } -import product.printed_chassis.fan_mount { - arctic_p8_fan_mount, FanMountAdmitted, -} -import product.printed_chassis.cadquery_realization { - plate_part_from_fan_mount, realize_plate_part, CadQueryEmitted, cadquery_program_source, -} -import product.printed_chassis.export_profile { exercised_stl_knobs } -import extdeps.cadquery.cadquery { stl_export_call } - -// First rear fan carrier part: the existing admitted 92 x 92 x 4 mm plate. -// Geometry and hole positions remain owned by fan_mount; no duplicate coordinates here. -// This emits an unpowered assembly-fit prototype, not qualified structural/powered hardware. -// Cassette attachment and a finger guard are separate, unresolved interfaces. -fn main() -> CliWireResponse { - match arctic_p8_fan_mount { - FanMountAdmitted { geometry: g } => match realize_plate_part(p: plate_part_from_fan_mount(g: g)) { - CadQueryEmitted { program: p } => CliWirePrintable { - bytes: concat(cadquery_program_source(p: p) as String, - stl_export_call(destination: "rear-fan-mount.stl", knobs: exercised_stl_knobs)), - exit: ExitSuccess, - }, - _ => CliWirePrintable { bytes: "", exit: exit_failure(reason: "Fan mount CAD emission refused") }, - }, - _ => CliWirePrintable { bytes: "", exit: exit_failure(reason: "Fan mount geometry refused") }, - } -} diff --git a/dag/gunbc/product/printed_chassis/fit_gauges.dag b/dag/gunbc/product/printed_chassis/fit_gauges.dag deleted file mode 100644 index bfe93241e47..00000000000 --- a/dag/gunbc/product/printed_chassis/fit_gauges.dag +++ /dev/null @@ -1,99 +0,0 @@ -module product.printed_chassis.fit_gauges - -import std.types { String, NonEmptyStr, List } -import extdeps.standards.micro_atx { - MountingLocationId, LocB, LocC, LocH, LocL, LocM, LocR, micro_atx_mounting_locations, -} -import extdeps.printing.bambu_lab_a1_mini { a1_mini_build_envelope } -import product.printed_chassis.standoff_gauge { - StandoffGaugeAdmission, GaugeAdmitted, GaugeLocationNotEnumerated, GaugeLocationDuplicated, - GaugeLocationsCoincide, GaugeExceedsBuildEnvelope, admit_standoff_gauge, - gauge_edge_margin, gauge_thickness, -} -import product.printed_chassis.cadquery_realization { - CadQueryEmitted, CadQueryDimensionUnrenderable, realize_plate_part, - plate_part_from_standoff_gauge, cadquery_program_source, -} -import product.printed_chassis.export_profile { exercised_stl_knobs } -import extdeps.cadquery.cadquery { stl_export_call } -import gunbc.cli_wire { CliWireResponse, CliWirePrintable } -import std.process { ExitSuccess, exit_failure } - -// Follow-on experiments to the existing F-C gauge. These five axis-aligned pairs connect -// the other five inferred board locations to that pair. J is explicitly excluded by the -// board authority and S is unobserved; neither earns a support by appearing in a standard. -// This roster selects experiments, not confirmed holes or structural support positions. -// The connected graph does not determine all relative angles: inspect against board edges -// and confirm the whole map in the adjustable fixture before freezing it. -// Each part uses the existing gauge admission, dimensions, build envelope and emitter. -type FitGaugePair { name: NonEmptyStr, a: MountingLocationId, b: MountingLocationId } - -data follow_on_fit_gauge_pairs: List = [ - FitGaugePair { name: "C-B", a: LocC, b: LocB }, - FitGaugePair { name: "C-H", a: LocC, b: LocH }, - FitGaugePair { name: "H-L", a: LocH, b: LocL }, - FitGaugePair { name: "L-M", a: LocL, b: LocM }, - FitGaugePair { name: "H-R", a: LocH, b: LocR }, -] - -fn fit_gauge_admission(pair: FitGaugePair) -> StandoffGaugeAdmission { - admit_standoff_gauge(rows: micro_atx_mounting_locations, envelope: a1_mini_build_envelope, - a: pair.a, b: pair.b, margin: gauge_edge_margin, thickness: gauge_thickness) -} - -// This generates a local experiment, not an absolute process-qualification receipt. -// The measured export knobs are reused without asserting that the unsettled profile is admitted. -// A refused experiment carries no executable source. -type FitGaugeSource = FitGaugeSourceReady { source: String } | FitGaugeSourceRefused { cause: NonEmptyStr } - -fn fit_gauge_source(pair: String) -> FitGaugeSource { - let matches = follow_on_fit_gauge_pairs |> filter(p => (p.name as String) == pair) - if (matches |> length) != 1 { - FitGaugeSourceRefused { cause: "expected exactly one declared fit gauge pair" } - } else { - match matches |> first { - Absent => FitGaugeSourceRefused { cause: "fit gauge pair absent" } - Present { value: p } => match fit_gauge_admission(pair: p) { - GaugeAdmitted { geometry: g } => match realize_plate_part(p: plate_part_from_standoff_gauge(g: g)) { - CadQueryEmitted { program: program } => FitGaugeSourceReady { source: concat( - cadquery_program_source(p: program) as String, - stl_export_call(destination: concat(p.name as String, ".stl"), knobs: exercised_stl_knobs)) }, - CadQueryDimensionUnrenderable { micrometres: _ } => FitGaugeSourceRefused { cause: "unrenderable gauge dimension" }, - }, - GaugeLocationNotEnumerated { requested: _ } => FitGaugeSourceRefused { cause: "gauge location absent" }, - GaugeLocationDuplicated { requested: _, count: _ } => FitGaugeSourceRefused { cause: "gauge location duplicated" }, - GaugeLocationsCoincide { requested: _ } => FitGaugeSourceRefused { cause: "gauge locations coincide" }, - GaugeExceedsBuildEnvelope { fit: _ } => FitGaugeSourceRefused { cause: "gauge exceeds build envelope" }, - }, - } - } -} - -// Machine-readable generated source, without terminal styling or any printer actuation. -fn main(pair: String) -> CliWireResponse { - fit_gauge_source_response(result: fit_gauge_source(pair: pair)) -} - -fn fit_gauge_source_response(result: FitGaugeSource) -> CliWireResponse { - match result { - FitGaugeSourceReady { source: s } => CliWirePrintable { bytes: s, exit: ExitSuccess }, - FitGaugeSourceRefused { cause: c } => CliWirePrintable { bytes: "", exit: exit_failure(reason: c) }, - } -} - -// One compiler load prepares the complete experiment set. Any refusal withholds the whole -// script, so a partial file cannot be mistaken for all five prepared gauges. -fn batch() -> CliWireResponse { - let source = fold(follow_on_fit_gauge_pairs, - init: FitGaugeSourceReady { source: "# Generated follow-on Altra fit gauges; experimental spacing checks.\n" }, - f: fn(acc, pair) { - match acc { - FitGaugeSourceRefused { cause: c } => FitGaugeSourceRefused { cause: c }, - FitGaugeSourceReady { source: previous } => match fit_gauge_source(pair: pair.name as String) { - FitGaugeSourceReady { source: next } => FitGaugeSourceReady { source: concat(previous, next) }, - FitGaugeSourceRefused { cause: c } => FitGaugeSourceRefused { cause: c }, - }, - } - }) - fit_gauge_source_response(result: source) -} diff --git a/dag/gunbc/product/printed_chassis/rack_assembly.dag b/dag/gunbc/product/printed_chassis/rack_assembly.dag deleted file mode 100644 index 77ad4c2d241..00000000000 --- a/dag/gunbc/product/printed_chassis/rack_assembly.dag +++ /dev/null @@ -1,45 +0,0 @@ -module product.printed_chassis.rack_assembly - -import std.types { Int, String, List } -import std.measure { micrometer_count } -import extdeps.cooling.dynatron { dynatron_w1_active_length,dynatron_w1_active_width,dynatron_w1_active_height } -import extdeps.fans.arctic { arctic_p8_frame_depth,arctic_p8_mounting_pattern } -import std.euclidean_geometry { Axis3Y, Axis3Z } -import product.printed_chassis.assembly_geometry { AssemblyPart, Primitive, Purchased, Metal, Clearance, Cassette, FixedFrame, LayOnXZ, LayOnXY, block,round,part } -import product.printed_chassis.cassette { tray_w,tray_d,board_w,board_d,bay_pitch,bay_w,rear_plate_z,fan_spacing } - -// Hardware bodies are catalog envelopes or explicit space reservations, not manufacturing CAD. -// Positions are design proposals; drives, GPUs and real connector pinouts remain unresolved. -fn hardware() -> List { - concat([ - part(id:"BOARD",name:"ALTRAD8UD-1L2T reference",kind:Clearance,scope:Cassette,adds:[block(w:board_w,h:1000,d:board_d,x:0,y:0,z:0)],cuts:[],x:0,y:27000,z:0,ex:0,ey:90000,ez:0,plane:LayOnXZ,notes:"Nominal board outline from vendor authority. 1 mm display slab is not measured PCB thickness. Elevation and socket/component placement provisional; board support hardware is unresolved."), - part(id:"CPU",name:"Dynatron W1 active envelope",kind:Purchased,scope:Cassette,adds:[block(w:micrometer_count(m:dynatron_w1_active_width),h:micrometer_count(m:dynatron_w1_active_height),d:micrometer_count(m:dynatron_w1_active_length),x:0,y:micrometer_count(m:dynatron_w1_active_height)/2,z:0)],cuts:[],x:0,y:30000,z:0,ex:0,ey:140000,ez:0,plane:LayOnXZ,notes:"Manufacturer active envelope 141.1 x 84 x 72 mm (dynatron.co/product-page/w1); simplified envelope. Location, orientation and installed offset provisional."), - part(id:"RAM1",name:"Left DIMM clearance",kind:Clearance,scope:Cassette,adds:[block(w:25000,h:40000,d:140000,x:0,y:20000,z:0)],cuts:[],x: -87000,y:27000,z:0,ex: -35000,ey:70000,ez:0,plane:LayOnXZ,notes:"Space reservation only; not surveyed DIMM positions or height."), - part(id:"RAM2",name:"Right DIMM clearance",kind:Clearance,scope:Cassette,adds:[block(w:25000,h:40000,d:140000,x:0,y:20000,z:0)],cuts:[],x:87000,y:27000,z:0,ex:35000,ey:70000,ez:0,plane:LayOnXZ,notes:"Space reservation only; not surveyed DIMM positions or height."), - part(id:"DC",name:"Isolatable DC disconnect reservation",kind:Clearance,scope:Cassette,adds:[block(w:24000,h:20000,d:18000,x:0,y:0,z:0)],cuts:[],x:tray_w/2-10000,y:22000,z:0-tray_d/2-12000,ex:45000,ey:30000,ez:0,plane:LayOnXZ,notes:"Rated/keyed connector not selected. One protected 12 V feed per node; shut down, isolate and disconnect before extraction. Not hot-swap."), - part(id:"CABLE",name:"Node harness corridor",kind:Clearance,scope:Cassette,adds:[block(w:10000,h:10000,d:tray_d-50000,x:0,y:0,z:0)],cuts:[],x:tray_w/2-15000,y:18000,z: -10000,ex:45000,ey:20000,ez:0,plane:LayOnXZ,notes:"Reserved route, not actual harness CAD; connector endpoints and bend radii pending."), - part(id:"IO",name:"Rear I/O service volume",kind:Clearance,scope:Cassette,adds:[block(w:board_w,h:45000,d:50000,x:0,y:0,z:0)],cuts:[],x:0,y:50000,z:0-tray_d/2-25000,ex:0,ey:40000,ez: -20000,plane:LayOnXZ,notes:"50 mm proposed service space ahead of rear fan bank. Actual rear connectors, cable bends and hand access require assembly review; this is not a measured keep-out.") - ],fold([-1,0,1],init:[],f:fn(acc,i){concat(acc,[ - part(id:concat("F",to_string(i+2)),name:"ARCTIC P8 rear exhaust",kind:Purchased,scope:Cassette, - adds:[block(w:micrometer_count(m:arctic_p8_mounting_pattern.frame_edge),h:micrometer_count(m:arctic_p8_mounting_pattern.frame_edge),d:micrometer_count(m:arctic_p8_frame_depth),x:0,y:0,z:0)],cuts:[round(diameter:73000,length:30000,axis:Axis3Z,x:0,y:0,z:0)], - x:i*fan_spacing,y:55000,z:rear_plate_z+2000+micrometer_count(m:arctic_p8_frame_depth)/2,ex:i*40000,ey:0,ez: -25000,plane:LayOnXY, - notes:"80 x 80 x 25 mm catalog frame; rotor omitted. Rear exhaust, three-fan layout proposal; thermal adequacy and PST header loading unqualified.") - ])})) -} -fn fixed_parts() -> List { - let rack_d=tray_d+150000 - let cz= -30000 - let posts=fold([-1,0,1],init:[],f:fn(acc,x){concat(acc,fold([-1,1],init:[],f:fn(inner,z){concat(inner,[block(w:14000,h:bay_pitch*2+45000,d:14000,x:x*bay_w,y:bay_pitch-12000,z:cz+z*(rack_d/2+18000))])}))}) - let beams=fold([-28000,bay_pitch-28000,bay_pitch*2+10000],init:[],f:fn(acc,y){concat(acc,concat( - fold([-1,1],init:[],f:fn(inner,z){concat(inner,[block(w:bay_w*2+14000,h:12000,d:14000,x:0,y:y,z:cz+z*(rack_d/2+18000))])}), - fold([-1,0,1],init:[],f:fn(inner,x){concat(inner,[block(w:14000,h:12000,d:rack_d+36000,x:x*bay_w,y:y,z:cz)])}) - ))}) - // Two fixed runners per node contact the outboard strip of each printed rail. - let runners=fold([-1,1],init:[],f:fn(acc,col){concat(acc,fold([0,1],init:[],f:fn(inner,row){concat(inner,fold([-1,1],init:[],f:fn(last,side){concat(last,[block(w:8000,h:6000,d:rack_d+36000,x:col*bay_w/2+side*(tray_w/2+6000),y:row*bay_pitch-7000,z:cz)])}))}))}) - let feeds=fold([-1,1],init:[],f:fn(acc,x){concat(acc,fold([0,1],init:[],f:fn(inner,row){concat(inner,[block(w:5000,h:bay_pitch*2+20000-row*bay_pitch,d:5000,x:x*bay_w/2+tray_w/2-10000,y:(row*bay_pitch+bay_pitch*2+64000)/2,z:0-tray_d/2-35000)])}))}) - [part(id:"FRAME",name:"Fixed metal 2 x 2 frame",kind:Metal,scope:FixedFrame,adds:concat(posts,concat(beams,runners)),cuts:[],x:0,y:0,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Metal stock proposal: posts 14 mm square and beams 14 x 12 mm. Cut lengths derived; 8 x 6 mm fixed runner proposals contact the outer printed rail strip. Actual stock, runner brackets, joining and stiffness to be selected. Not printable."), - part(id:"SHELF",name:"Shared power shelf",kind:Metal,scope:FixedFrame,adds:[block(w:bay_w*2,h:8000,d:rack_d,x:0,y:0,z:0)],cuts:[],x:0,y:bay_pitch*2+28000,z:cz,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Top shelf proposal; metal gauge and fastening unqualified. Fixed protective earth must survive cassette removal."), - part(id:"PSU",name:"Shared 12 V supply reservation",kind:Clearance,scope:FixedFrame,adds:[block(w:150000,h:85000,d:150000,x:0,y:42500,z:0)],cuts:[],x:0,y:bay_pitch*2+32000,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"One supply system per four nodes. 150 x 85 x 150 mm is a space reservation, not a selected model. Wattage and redundancy unresolved; 400-600 W per-node example is superseded."), - part(id:"PDB",name:"Four-branch distribution reservation",kind:Clearance,scope:FixedFrame,adds:[block(w:150000,h:30000,d:35000,x:0,y:0,z:0)],cuts:[],x:0,y:bay_pitch*2+55000,z:0-rack_d/2-15000,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Four protected, isolatable branches. No circuit, ratings, wire gauge or connector pinouts invented."), - part(id:"FEEDS",name:"Fixed branch route reservations",kind:Clearance,scope:FixedFrame,adds:feeds,cuts:[],x:0,y:0,z:0,ex:0,ey:0,ez:0,plane:LayOnXZ,notes:"Four schematic branch corridors remain on the frame when a node is removed. Actual distribution routing and disconnect positioning require review.")] -} diff --git a/dag/gunbc/product/printed_chassis/review_realization.dag b/dag/gunbc/product/printed_chassis/review_realization.dag deleted file mode 100644 index 667765b2695..00000000000 --- a/dag/gunbc/product/printed_chassis/review_realization.dag +++ /dev/null @@ -1,195 +0,0 @@ -module product.printed_chassis.review_realization - -import std.types { String } -import gunbc.cli_wire { CliWireResponse, CliWirePrintable } -import std.process { ExitSuccess } - -// Review-only host adapter. Mechanical geometry, placements, materials and tolerances -// are inputs from cassette_review, never decisions made here. This emits no printer -// command and grants no AdmittedRealizationV0 / fabrication capability. The existing -// admission workflow remains mandatory after assembly review and before printing. -// Exercise with CadQuery 2.8.0; rejection is atomic (no partial review package). -// Python is emitted transiently because this repository owns tools in .dag/.rs. -fn adapter_source() -> String { join([ - "#!/usr/bin/env python3\n", - "\"\"\"Realize a DAG-emitted review manifest. No cassette dimensions live in this adapter.\n", - "\n", - "CadQuery 2.8.0 is the exercised kernel. Only the closed box/cylinder union/difference\n", - "vocabulary is accepted; unknown operations, disconnected printed solids and oversized\n", - "parts refuse. This is review-output validation, not powered/structural qualification.\n", - "\"\"\"\n", - "import argparse, collections, csv, hashlib, io, json, math, pathlib, re, shutil, tempfile, zipfile\n", - "import cadquery as cq\n", - "from OCP.Bnd import Bnd_Box\n", - "from OCP.BRepBndLib import BRepBndLib\n", - "from vtkmodules.vtkIOGeometry import vtkSTLReader\n", - "from vtkmodules.vtkFiltersCore import vtkFeatureEdges, vtkMassProperties\n", - "\n", - "\n", - "def mm(values):\n", - " assert all(type(v) is int for v in values), 'Expected integer micrometres'\n", - " return [v / 1000 for v in values]\n", - "\n", - "\n", - "def primitive(row):\n", - " center = mm(row['center_um'])\n", - " if row['op'] == 'box':\n", - " size = mm(row['size_um']); assert min(size) > 0\n", - " return cq.Workplane('XY').box(*size).val().translate(center)\n", - " if row['op'] == 'cylinder':\n", - " diameter, length = mm([row['diameter_um'], row['length_um']]); assert min(diameter,length) > 0\n", - " axes = \{'x':(1,0,0), 'y':(0,1,0), 'z':(0,0,1)\}\n", - " axis = axes[row['axis']]\n", - " start = tuple(c-a*length/2 for c,a in zip(center,axis))\n", - " return cq.Solid.makeCylinder(diameter/2, length, cq.Vector(*start), cq.Vector(*axis))\n", - " raise ValueError(f\"Unknown modeled primitive: \{row['op']\}\")\n", - "\n", - "\n", - "def exact_bounds(shape):\n", - " b=Bnd_Box(); BRepBndLib.AddOptimal_s(shape.wrapped,b,False,False)\n", - " return b.Get()\n", - "\n", - "def extent(shape):\n", - " b=exact_bounds(shape); return [b[i+3]-b[i] for i in range(3)]\n", - "\n", - "\n", - "def near(a,b,tolerance=2e-5):\n", - " assert len(a)==len(b) and all(abs(x-y)0\n", - " e=vtkFeatureEdges(); e.SetInputData(mesh); e.BoundaryEdgesOn(); e.NonManifoldEdgesOn(); e.FeatureEdgesOff(); e.ManifoldEdgesOff(); e.Update()\n", - " assert e.GetOutput().GetNumberOfCells()==0, 'Open/nonmanifold STL'\n", - " b=mesh.GetBounds(); near([b[1]-b[0],b[3]-b[2],b[5]-b[4]],extent(shape),2*tolerance+2e-5)\n", - " mass=vtkMassProperties();mass.SetInputData(mesh);mass.Update()\n", - " assert math.isclose(mass.GetVolume(),shape.Volume(),rel_tol=3e-4), 'STL volume divergence'\n", - " return mesh.GetNumberOfCells()\n", - "\n", - "\n", - "def primitive_bounds(p):\n", - " c=mm(p['center_um'])\n", - " if p['op']=='box': s=mm(p['size_um'])\n", - " else:\n", - " s=[p['diameter_um']/1000]*3;s['xyz'.index(p['axis'])]=p['length_um']/1000\n", - " return [c[i]-s[i]/2 for i in range(3)]+[c[i]+s[i]/2 for i in range(3)]\n", - "\n", - "\n", - "def model_bounds(p):\n", - " b=[primitive_bounds(a) for a in p['adds']]\n", - " return [min(r[i] for r in b) for i in range(3)]+[max(r[i+3] for r in b) for i in range(3)]\n", - "\n", - "\n", - "def overlaps(a,b):\n", - " return all(min(a[i+3],b[i+3])-max(a[i],b[i])>1e-5 for i in range(3))\n", - "\n", - "\n", - "def realize(model, out):\n", - " assert model['schema']=='gunbc.cassette-review.v1' and model['quantum']=='micrometre'\n", - " assert model['release']=='review-only; no print or powered-operation admission'\n", - " assert cq.__version__=='2.8.0', 'Use the recorded CadQuery 2.8.0 environment'\n", - " ids=[p['id'] for p in model['parts']];assert len(set(ids))==len(ids)\n", - " assert all(re.fullmatch(r'[A-Za-z0-9_-]+',i) for i in ids)\n", - " for interface in model['interfaces']:\n", - " assert set(interface['members'])<=set(ids), interface\n", - " # Every modeled fastener bore must have a coaxial mate in another printed part.\n", - " # Diameter classes come from the logical model, not adapter design choices.\n", - " axes=collections.defaultdict(set)\n", - " for p in model['parts']:\n", - " if p['kind']!='printed':continue\n", - " for c in p['cuts']:\n", - " if c['op']=='cylinder' and c['diameter_um'] in model['fastener_clearance_um']:\n", - " xyz=[a+b for a,b in zip(c['center_um'],p['at_um'])]\n", - " axial='xyz'.index(c['axis'])\n", - " key=(c['axis'],c['diameter_um'],*[v for i,v in enumerate(xyz) if i!=axial])\n", - " axes[key].add(p['id'])\n", - " assert all(len(v)>=2 for v in axes.values()), 'Fastener bore without coaxial mate'\n", - " shapes=\{\}; world=\{\}; bounds=\{\}; groups=\{\}; errors=[]\n", - " tol=model['export']['linear_tolerance_um']/1000\n", - " angular=float(model['export']['angular_tolerance_radians'])\n", - " for p in model['parts']:\n", - " try:\n", - " assert p['adds'], 'No positive geometry (upstream admission refused?)'\n", - " body=primitive(p['adds'][0])\n", - " for a in p['adds'][1:]: body=body.fuse(primitive(a))\n", - " body=body.clean()\n", - " for cut in p['cuts']:\n", - " tool=primitive(cut)\n", - " assert body.intersect(tool).Volume()>1e-6, f'Cut misses material: \{cut\}'\n", - " body=body.cut(tool)\n", - " body=body.clean(); assert body.isValid() and body.Volume()>0\n", - " if p['kind']=='printed': assert len(body.Solids())==1, f'\{len(body.Solids())\} disconnected solids'\n", - " expected=model_bounds(p)\n", - " near(extent(body),[expected[i+3]-expected[i] for i in range(3)])\n", - " vertices, faces=body.tessellate(tol,angular)\n", - " mesh=\{'vertices':[c for v in vertices for c in v.toTuple()], 'indices':[i for f in faces for i in f]\}\n", - " (out/f'\{p[\"id\"]\}.mesh.json').write_text(json.dumps(mesh,separators=(',',':')))\n", - " p['mesh']=f'\{p[\"id\"]\}.mesh.json';p['extent_mm']=extent(body);p['volume_mm3']=body.Volume()\n", - " shapes[p['id']]=body;at=mm(p['at_um']);world[p['id']]=body.translate(at)\n", - " bounds[p['id']]=[expected[i]+at[i%3] for i in range(6)]\n", - " if p['kind']=='printed':\n", - " oriented=body\n", - " if p['print_plane']=='XZ':oriented=body.rotate((0,0,0),(1,0,0),90)\n", - " elif p['print_plane']=='YZ':oriented=body.rotate((0,0,0),(0,1,0),-90)\n", - " else:assert p['print_plane']=='XY'\n", - " b=exact_bounds(oriented);oriented=oriented.translate(tuple(-v for v in b[:3]))\n", - " assert all(n<=limit for n,limit in zip(extent(oriented),model['print_envelope_mm'])), 'Build envelope exceeded'\n", - " stl=out/f'\{p[\"id\"]\}.stl';oriented.exportStl(str(stl),tolerance=tol,angularTolerance=angular,ascii=False,relative=False,parallel=True)\n", - " triangles=mesh_check(stl,oriented,tol)\n", - " step=out/f'\{p[\"id\"]\}.step';cq.exporters.export(body,str(step),exportType='STEP')\n", - " readback=cq.importers.importStep(str(step)).val();near(extent(readback),extent(body))\n", - " assert math.isclose(readback.Volume(),body.Volume(),rel_tol=1e-8)\n", - " p.update(stl=stl.name,step=step.name,print_extent_mm=extent(oriented),triangles=triangles,sha256=hashlib.sha256(stl.read_bytes()).hexdigest(),checks='single solid; STEP round-trip; closed STL; volume/bounds; bed fit')\n", - " identity=hashlib.sha256(json.dumps(\{k:p[k] for k in ['adds','cuts','print_plane']\},sort_keys=True).encode()).hexdigest()\n", - " groups.setdefault(identity,[]).append(p['id'])\n", - " print('OK',p['id'],p['name'],flush=True)\n", - " except Exception as exc:\n", - " errors.append(f'\{p[\"id\"]\}: \{exc\}');print('REFUSED',errors[-1],flush=True)\n", - " if errors: raise ValueError('\\n'.join(errors))\n", - " # Exact CAD intersections between moving printed parts, after a broad-phase AABB check.\n", - " printed=[p for p in model['parts'] if p['kind']=='printed'];collisions=[]\n", - " for n,a in enumerate(printed):\n", - " for b in printed[n+1:]:\n", - " if overlaps(bounds[a['id']],bounds[b['id']]):\n", - " volume=world[a['id']].intersect(world[b['id']]).Volume()\n", - " if volume>1e-4:collisions.append(\{'a':a['id'],'b':b['id'],'volume_mm3':volume\})\n", - " if collisions:raise ValueError('Printed-part collisions: '+json.dumps(collisions))\n", - " model['validation']=\{'cadquery':cq.__version__,'printed_part_count':len(printed),'unique_print_geometries':len(groups),'printed_pair_collisions':collisions,'matched_fastener_axes':len(axes),'scope':'geometry only; no fastener engagement, thermal, structural or electrical qualification'\}\n", - " model['print_groups']=[\{'representative':items[0],'quantity':len(items),'instances':items\} for items in groups.values()]\n", - " assembly=cq.Assembly()\n", - " for p in model['parts']:\n", - " if p['scope']=='cassette' and p['kind']!='clearance':assembly.add(shapes[p['id']],name=p['id'],loc=cq.Location(cq.Vector(*mm(p['at_um']))))\n", - " assembly.export(str(out/'cassette-assembly.step'))\n", - " (out/'assembly.json').write_text(json.dumps(model,indent=2)+'\\n')\n", - " with (out/'parts.csv').open('w') as f:\n", - " w=csv.writer(f);w.writerow(['id','part','kind','scope','x_mm','y_mm','z_mm','notes'])\n", - " for p in model['parts']:w.writerow([p['id'],p['name'],p['kind'],p['scope'],*p['extent_mm'],p['notes']])\n", - " with (out/'interfaces.csv').open('w') as f:\n", - " w=csv.writer(f);w.writerow(['interface','members','fastener','quantity','status','requirement'])\n", - " for i in model['interfaces']:w.writerow([i['id'],' '.join(i['members']),i['fastener'],i['quantity'],i['status'],i['requirement']])\n", - " with (out/'print-list.csv').open('w') as f:\n", - " w=csv.writer(f);w.writerow(['representative','quantity','instances','print_file'])\n", - " for g in model['print_groups']:w.writerow([g['representative'],g['quantity'],' '.join(g['instances']),g['representative']+'.stl'])\n", - " (out/'README.txt').write_text('Cassette r05 review package. Generated from product.printed_chassis.cassette_review.\\n'+model['release']+'\\nSTLs are bed-oriented; individual STEPs and assembly STEP use assembly-local axes.\\nparts.csv / print-list.csv list all parts and repeat quantities. One M fan plate is already fit-tested.\\nNo additional gauge sequence. Review assembly, fasteners and unresolved board support before scheduling cassette prints.\\n')\n", - " with zipfile.ZipFile(out/'cassette-review.zip','w',zipfile.ZIP_DEFLATED) as z:\n", - " for f in sorted(out.iterdir()):\n", - " if f.suffix in ['.stl','.step','.csv','.txt'] or f.name=='assembly.json':z.write(f,f.name)\n", - "\n", - "\n", - "def main():\n", - " ap=argparse.ArgumentParser();ap.add_argument('manifest',type=pathlib.Path);ap.add_argument('output',type=pathlib.Path);args=ap.parse_args()\n", - " model=json.loads(args.manifest.read_text());model['source_manifest_sha256']=hashlib.sha256(args.manifest.read_bytes()).hexdigest()\n", - " args.output.parent.mkdir(parents=True,exist_ok=True)\n", - " staging=pathlib.Path(tempfile.mkdtemp(prefix='cad-review-',dir=args.output.parent))\n", - " try:\n", - " realize(model,staging)\n", - " if args.output.exists():raise ValueError('Output already exists; choose a fresh version directory')\n", - " staging.rename(args.output)\n", - " except Exception:\n", - " shutil.rmtree(staging);raise\n", - " print(json.dumps(model['validation']))\n", - "\n", - "if __name__=='__main__':main()\n" -], "") } -fn main() -> CliWireResponse { CliWirePrintable {bytes:adapter_source(),exit:ExitSuccess} } diff --git a/dag/test/claim/fit_gauges_witness_test.dag b/dag/test/claim/fit_gauges_witness_test.dag deleted file mode 100644 index 05f96d247a2..00000000000 --- a/dag/test/claim/fit_gauges_witness_test.dag +++ /dev/null @@ -1,35 +0,0 @@ -module test.claim.fit_gauges_witness_test - -import std.types { Bool } -import product.printed_chassis.fit_gauges { follow_on_fit_gauge_pairs, fit_gauge_admission, fit_gauge_source, FitGaugeSourceReady, FitGaugeSourceRefused } -import product.printed_chassis.standoff_gauge { - GaugeAdmitted, GaugeLocationNotEnumerated, GaugeLocationDuplicated, - GaugeLocationsCoincide, GaugeExceedsBuildEnvelope, -} -import extdeps.standards.micro_atx { LocJ, LocS } - -test fn w_every_follow_on_gauge_fits_the_actual_printer_envelope() -> Bool { - (follow_on_fit_gauge_pairs |> length) > 0 && - fold(follow_on_fit_gauge_pairs, init: true, f: fn(ok, pair) { - ok && match fit_gauge_admission(pair: pair) { - GaugeAdmitted { geometry: _ } => true, - GaugeLocationNotEnumerated { requested: _ } => false, - GaugeLocationDuplicated { requested: _, count: _ } => false, - GaugeLocationsCoincide { requested: _ } => false, - GaugeExceedsBuildEnvelope { fit: _ } => false, - } - }) -} - -test fn w_no_follow_on_gauge_asks_for_an_excluded_or_unobserved_board_hole() -> Bool { - fold(follow_on_fit_gauge_pairs, init: true, f: fn(ok, pair) { - ok && pair.a != LocJ && pair.b != LocJ && pair.a != LocS && pair.b != LocS - }) -} - -test fn w_unknown_pair_cannot_emit_a_default_print() -> Bool { - match fit_gauge_source(pair: "C-J") { - FitGaugeSourceRefused { cause: _ } => true, - FitGaugeSourceReady { source: _ } => false, - } -} diff --git a/docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png b/docs/extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png deleted file mode 100644 index 1fc86f89410eb6f476ade996c9cb846bc2c8700a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 156177 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 A%m4rY diff --git a/sites/.gitignore b/sites/.gitignore deleted file mode 100644 index bb544ad8a7d..00000000000 --- a/sites/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# This hosted Site has its own versioned source repository managed by Sites. -/chassis-workbench/ diff --git a/sites/README.md b/sites/README.md deleted file mode 100644 index aa2322a3c1f..00000000000 --- a/sites/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Chassis workbench - -[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site) is the private -assembly review site. Reuse project `appgprj_6ac187f2fe208191b1ec7ad6703d93e8` and the separate -Sites-managed checkout at `sites/chassis-workbench/`; do not create a second site. - -Review 05 consumes meshes and metadata generated from `product.printed_chassis.cassette_review`. -Mechanical geometry, placements, print orientation and interfaces belong to `.dag`. The browser -only displays those meshes; the former `concept.js` and mechanical sliders are removed. View -controls change the camera, visibility, explosion and cassette withdrawal, never printable geometry. - -- Carrier: all 49 printed instances, purchased hardware and labeled clearance reservations. -- All pieces: 29 unique authored print geometries with required quantities; isolate and rotate any. -- Rack: four cassettes in a fixed metal frame, fixed runners and one shared-power reservation. -- Downloads: individual STL/STEP, assembly STEP, logical manifest and a complete review ZIP with - parts, print quantities and interface hardware lists. -- Assembly review: the passed fan fit, proposed joints and remaining hardware decisions. - -The tray is 271.840 × 294.700 mm, split for the 180 mm A1 mini. The custom frame uses 140 mm bay -pitch; it is not a standard rack-unit compliance claim. Dynatron W1 and P8 envelopes come from -vendor authority. One fan plate has already physically fit; reuse it when counting the next prints. -Shared 12 V power remains fixed per four-node block with four isolatable/protected feeds. Capacity, -branch circuitry, connector choice and redundancy remain unresolved. Board supports are explicitly -unresolved rather than invented geometry. See `docs/plans/printed-chassis-program.md` for evidence, -reproduction, first-assembly scope and the existing fabrication/printing workflow. - -CadQuery validation covers all printed solids, STL manifoldness/bounds/volume, STEP round-trip, -180 mm bed fit, 56 matched fastener axes and zero positive-volume intersections between printed -parts. It does not qualify strength, engagement, thermals or electrical operation. Local browser -checks cover generated mesh loading, roster, selection/isolation/keyboard rotation, rack extraction, -all-pieces view, filters, hardware/clearance visibility, downloads, interface table and mobile layout. -There are no printer controls, credentials, external application access or persistent user data. - -Earlier visual-only source: concept 04 `c2a49273da70f20a23e2e044fe552dceb74b773f`. - -Published review 05 Site source: `a9658fd05d27e83220c611b5fb00ac4397b74739` (owner-private audience unchanged). From 8fc65ac6675beabc19e7abbfaf993adcb3b493d6 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 05:16:29 +0000 Subject: [PATCH 20/24] Classify attended print requests and separate CAD and slicer scopes --- dag/extdeps/printing/orca_slicer.dag | 86 +-- .../auth/authorization_pattern_selection.dag | 5 +- dag/gunbc/auth/privileged_effect_census.dag | 4 +- dag/gunbc/fleet/fleet_converge_workflow.dag | 2 +- .../product/printed_chassis/slice_run.dag | 56 -- .../printed_chassis/slicing_toolchain.dag | 5 +- dag/std/human_intervention.dag | 14 +- .../review_sheet_converge_witness_test.dag | 12 +- docs/plans/printed-chassis-program.md | 618 +----------------- docs/plans/printer-workflow-review.md | 26 + 10 files changed, 131 insertions(+), 697 deletions(-) delete mode 100644 dag/gunbc/product/printed_chassis/slice_run.dag create mode 100644 docs/plans/printer-workflow-review.md diff --git a/dag/extdeps/printing/orca_slicer.dag b/dag/extdeps/printing/orca_slicer.dag index 7a2fccbe32e..e4ae0bef505 100644 --- a/dag/extdeps/printing/orca_slicer.dag +++ b/dag/extdeps/printing/orca_slicer.dag @@ -2,13 +2,7 @@ module extdeps.printing.orca_slicer import extdeps.exec.program { uncataloged_program } -import std.types { String, NonEmptyStr, List, FilePath } -import extdeps.shell -import extdeps.shell.exec { ProcessOutcome, ProcessOutputPresent, ProcessOutputAbsent, ProcessRefused } -import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } -import extdeps.tools.chmod { chmod_set_mode_command, chmod_path_resolved_program } -import gunbc.command_runner { run_shell_command_capture } -import extdeps.exec.command { LocalExec } +import std.types { String, NonEmptyStr, List } import std.nat { Nat } import std.decl_ref { DeclarationRef, WholeDeclaration } import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } @@ -86,12 +80,49 @@ data orca_slicer_linux_aarch64_appimage_sha256: NonEmptyStr = "e1a07275a25f17662 // clearing the floor as "runs" is overstating its own measurement. data orca_slicer_required_glibc: GlibcVersion = glibc_version(major: 2, minor: 38) -// A capability may be spent only inside with_verified_orca's private-copy lifetime. -// The archive is copied first and the copy is checked against the selected asset's digest. -// The callback can return only a process result, never the capability. Both probe and slice -// execute that same copy through AppImage's extract-and-run runtime, which removes its extraction. -// The private realization protects against replacing the caller's original archive. Like the -// printer payload bracket, it does not defend against a hostile process running as this user. +// THE EXECUTABLE IS ITS OWN SUBJECT, SEALED, AND THIS MODULE IS THE ONLY PLACE THAT CAN SAY WHAT +// COUNTS AS ORCA. A consumer that took a bare ArgvCommand could run `true`, observe a genuine zero +// exit, and carry the result forward as evidence that a slicer executes -- generic executability +// read as ORCA executability. The subject was not substituted there and the receipt was not faked; +// the executed thing was simply of the wrong kind, which no amount of care at the consumer can see. +// +// The kind is therefore constructional rather than checked: OrcaExecutable has one constructor, so a +// caller cannot spell an Orca invocation over some other program. What that constructor CONSUMES is +// settled below, and today the answer is that nothing does. That is DESIGN section 3 as much as +// section 5: what counts as this upstream product is a fact this module owns, and a consumer +// comparing a program string to "orca-slicer" would be a second authority for it -- and a string +// check besides, which the AppImage's own filename already shows is unreliable. +// +// AND THERE IS NO MINT, WHICH IS THE HONEST STATE RATHER THAN A GAP. +// +// The kind question was real: `true --version` exits zero on every host, so a consumer taking a bare +// ArgvCommand could read generic executability as ORCA executability. Sealing the executable answers +// it, and the mint must consume evidence rather than a string -- an unrestricted +// `orca_executable(program: NonEmptyStr)` would hand back the very authority the seal holds, and +// restricting its CALLERS only moves that one function outward. +// +// A DIGEST CHECK AT MINT TIME IS NOT ENOUGH, and this is where the previous revision stopped. It +// verified the bytes at a path and then stored THE PATH. A path is a mutable locator, not the +// content identity that earned the seal: verify the real AppImage, receive the seal, replace the +// file, and the capability is spent against whatever now occupies that path. True when minted, false +// when used. That matters most in this module, which insists the 2.38 floor belongs to THIS +// content -- so a seal that decays to a name reintroduces exactly the inheritance it refuses. +// +// gunbc.package_delivery already refuses to treat a staging path as durable receipt identity, and +// the Spark runtime publishes into a root derived from materialization identity rather than trusting +// an arbitrary mutable path. Either shape would close this: re-verify at every positive spend, or +// materialize into an owned content-addressed realization the probe and the slice both consume. +// +// NEITHER BELONGS IN THIS PR, so the positive capability stays UNINHABITED. There is no way to +// obtain an OrcaExecutable, and therefore no way to reach ExecutionReady or a SlicePlan. The direct- +// host route keeps what it actually established -- the measured ABI floor, and two recorded hosts +// that cleared it and still refused -- which is useful and honest on its own. A coproduct having a +// Ready arm is not a reason to manufacture a way to reach it; that is the same fabrication as the +// three slicing witnesses this PR deleted and the export positive control it just deleted. +// +// NEXT-RUNG TRIGGER: a content-addressed materialization of the selected asset, from which the +// executable capability is named and which the probe and slice invocations both consume, so the +// bytes that were verified are the bytes that run. type OrcaExecutable sole_constructor { program: NonEmptyStr } fn orca_executable_program(e: OrcaExecutable) -> NonEmptyStr { e.program } @@ -100,7 +131,7 @@ fn orca_executable_program(e: OrcaExecutable) -> NonEmptyStr { e.program } // cheapest Orca-shaped command there is, it needs no display, and it exits zero only if the runtime // closure resolved -- which is exactly the fact a would-be slicer needs before a slice is planned. fn orca_version_probe_command(executable: OrcaExecutable) -> ArgvCommand { - argv_command(program: uncataloged_program(invocation: executable.program), arguments: ["--appimage-extract-and-run", "--help"]) + argv_command(program: uncataloged_program(invocation: executable.program), arguments: ["--version"]) } // THE CLI, WHICH IS THE ONLY INTERFACE THIS REPOSITORY USES. The desktop application is the same @@ -116,8 +147,6 @@ fn orca_slice_to_project_command( argv_command( program: uncataloged_program(invocation: executable.program), arguments: [ - "--appimage-extract-and-run", - "--arrange", "1", "--orient", "0", "--ensure-on-bed", "--slice", "0", "--load-settings", concat(concat(printer_profile, ";"), process_profile), "--load-filaments", filament_profile, @@ -126,28 +155,3 @@ fn orca_slice_to_project_command( ], ) } - -// The caller's copy is never executed, and a failed digest cannot mint the capability. -fn with_verified_orca(appimage: FilePath, use: fn(OrcaExecutable) -> ProcessOutcome) -> ProcessOutcome { - let temp = shell.Mktemp.Dir() - if !temp.success { return ProcessRefused { exit_code: 1, stderr: "Cannot create private Orca realization" } } - let path = join([temp.path as String, "/", orca_slicer_linux_aarch64_appimage_sha256 as String, ".AppImage"], "") - let result = if !shell.Copy.File(source: appimage, destination: path as FilePath).success { - ProcessRefused { exit_code: 1, stderr: "Cannot copy Orca archive" } - } else { - match sha256sum_file_digest_via_shell(path: path as NonEmptyStr) { - Sha256FileDigestUnavailable { path: _, reason: r } => ProcessRefused { exit_code: 1, stderr: r as String } - Sha256FileDigest { digest: d } => - if (d.hex as String) != (orca_slicer_linux_aarch64_appimage_sha256 as String) { - ProcessRefused { exit_code: 1, stderr: "Orca archive differs from the selected upstream digest; not executed" } - } else { - match run_shell_command_capture(command: chmod_set_mode_command(chmod_program: chmod_path_resolved_program, mode: "0500", path: path), transport: LocalExec) { - ProcessRefused { exit_code: c, stderr: e } => ProcessRefused { exit_code: c, stderr: e } - _ => use(OrcaExecutable { program: path as NonEmptyStr }) - } - } - } - } - if shell.Remove.RecursiveForce(path: temp.path as FilePath).success { result } - else { ProcessRefused { exit_code: 1, stderr: concat("Orca realization cleanup failed at ", temp.path as String) } } -} diff --git a/dag/gunbc/auth/authorization_pattern_selection.dag b/dag/gunbc/auth/authorization_pattern_selection.dag index 250c03a864c..cf39c8e6985 100644 --- a/dag/gunbc/auth/authorization_pattern_selection.dag +++ b/dag/gunbc/auth/authorization_pattern_selection.dag @@ -17,7 +17,7 @@ import std.decision { select_realization, } import std.human_intervention { - HumanIntervention, HumanInterventionFrequency, FleetOnce, DeviceOnce, EveryProvision, BreakGlassOnly, + HumanIntervention, HumanInterventionFrequency, FleetOnce, DeviceOnce, EveryProvision, EveryOperatorRequest, BreakGlassOnly, DischargedAt, NoDischargeSiteModeled, } import extdeps.external_authority { ExternalAuthority, CitedToAuthority } @@ -161,6 +161,7 @@ fn witness_required(e: PrivilegedEffect) -> Bool { BreakGlassOnly => true FleetOnce => false DeviceOnce => false + EveryOperatorRequest => false EveryProvision => false } irreversible_undischarged || wide || bills || break_glass @@ -171,6 +172,7 @@ fn witness_required(e: PrivilegedEffect) -> Bool { // operator learns to tap without reading. fn unattended_required(e: PrivilegedEffect) -> Bool { match e.frequency { + EveryOperatorRequest => false EveryProvision => true FleetOnce => false DeviceOnce => false @@ -512,6 +514,7 @@ fn frequency_text(f: HumanInterventionFrequency) -> String { match f { FleetOnce => "FleetOnce" DeviceOnce => "DeviceOnce" + EveryOperatorRequest => "EveryOperatorRequest" EveryProvision => "EveryProvision" BreakGlassOnly => "BreakGlassOnly" } diff --git a/dag/gunbc/auth/privileged_effect_census.dag b/dag/gunbc/auth/privileged_effect_census.dag index 0f4040a20b9..c24f8a2a8e6 100644 --- a/dag/gunbc/auth/privileged_effect_census.dag +++ b/dag/gunbc/auth/privileged_effect_census.dag @@ -9,7 +9,7 @@ import std.dissolution { DissolutionCondition, DissolutionStatus, DissolutionFired, DissolutionPending, DissolutionUnbound, dissolution_status, unbound_dissolution, retires_dissolution, } -import std.human_intervention { FleetOnce, DeviceOnce, EveryProvision, BreakGlassOnly } +import std.human_intervention { FleetOnce, DeviceOnce, EveryProvision, EveryOperatorRequest, BreakGlassOnly } import std.roster_frontier { FrontierRow, frontier_row_decl } import gunbc.spark.bootstrap_provision { fleet_cloud_principal_standing, @@ -504,7 +504,7 @@ fn present_trigger_declarations() -> List { // the selected authority for that irreversible effect; the common start gate also // verifies approval, observes IDLE and atomically claims the approved escalation. fn printer_start_effect() -> PrivilegedEffect { - PrivilegedEffect {subject:"start one digest-bound physical printer job",frequency:EveryProvision, + PrivilegedEffect {subject:"start one digest-bound physical printer job",frequency:EveryOperatorRequest, reversibility:IrreversibleEffect {what_is_lost:"filament and the prior physical bed state"},surface:ApiSurface, workload_identity:WorkloadIdentityBindable {member:federated_service_account_member(email:printer_service_account) as NonEmptyStr}, minted_reach:MintsNoCredential,billing:NoBillingConsequence,witness_discharge:NoWitnessDischarge} diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index fcebeb88ef9..06cadee9d0f 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -506,7 +506,7 @@ fn fleet_converge_mode_release_product(mode: FleetConvergeWorkflowMode) -> Fleet MicrovmRunnerGroupEnsure => SixteenBinaryPack GcpIamConverge => SixteenBinaryPack NamecheapObserve => SixteenBinaryPack - PrinterWorkflow => CompilerPairOnly + PrinterWorkflow => SixteenBinaryPack } } diff --git a/dag/gunbc/product/printed_chassis/slice_run.dag b/dag/gunbc/product/printed_chassis/slice_run.dag deleted file mode 100644 index 068a1b681a2..00000000000 --- a/dag/gunbc/product/printed_chassis/slice_run.dag +++ /dev/null @@ -1,56 +0,0 @@ -module product.printed_chassis.slice_run - -import std.types { String, FilePath, List } -import std.process { ProcessExit, ExitSuccess, exit_failure } -import extdeps.printing.orca_slicer { with_verified_orca, orca_version_probe_command, orca_slice_to_project_command } -import extdeps.exec.command { LocalExec } -import extdeps.shell.exec { ProcessRefused } -import gunbc.command_runner { run_shell_command_capture } -import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed } -import extdeps.filesystem.filesystem_io { Filesystem } -import extdeps.languages.json.emit { JsonKeyValue, JsonObject, json_object, json_kv, json_string, serialize_json } - -// Selected slicing settings, not a claim of live printer telemetry. The 2 mm brim -// keeps the 170.94 mm gauge inside the 180 mm envelope without scaling the gauge. -fn fit_prototype_settings_members() -> List { - [ - json_kv(key: "type", value: json_string(s: "process")), - json_kv(key: "from", value: json_string(s: "user")), - json_kv(key: "name", value: json_string(s: "Unpowered fit prototype 0.20 mm PLA")), - json_kv(key: "curr_bed_type", value: json_string(s: "Textured PEI Plate")), - json_kv(key: "brim_type", value: json_string(s: "outer_only")), - json_kv(key: "brim_width", value: json_string(s: "2")), - json_kv(key: "skirt_loops", value: json_string(s: "0")), - json_kv(key: "enable_support", value: json_string(s: "0")), - ] -} - -// Runs the exact pinned upstream artifact over explicitly supplied model and profiles. -// A successful process result must still be followed by project/G-code readback before printing. -fn run(appimage: String, model: String, output: String, machine: String, filament: String, process: String) -> ProcessExit { - let settings = concat(output, ".settings.json") - let original = Filesystem.Read(path: process) - if !original.success { return exit_failure(reason: "Cannot read base process profile") } - let overrides = fit_prototype_settings_members() - let content = match parse_json_document(s: original.content) { - JsonDocumentParsed { value: JsonObject { members: members } } => - serialize_json(v: json_object(members: concat(filter(members, m => !any(overrides, o => o.key == m.key)), overrides))) - _ => "" - } - if content == "" || !Filesystem.Write(path: settings, content: content).success { - return exit_failure(reason: "Cannot parse or write fit-prototype process profile") - } - match with_verified_orca(appimage: appimage as FilePath, use: executable => { - let probe = run_shell_command_capture(command: orca_version_probe_command(executable: executable), transport: LocalExec) - match probe { - ProcessRefused { exit_code: _, stderr: _ } => probe - _ => run_shell_command_capture(command: orca_slice_to_project_command( - executable: executable, input_model: model, output_project: output, - printer_profile: machine, filament_profile: filament, process_profile: settings, - ), transport: LocalExec) - } - }) { - ProcessRefused { exit_code: _, stderr: e } => exit_failure(reason: e) - _ => ExitSuccess - } -} diff --git a/dag/gunbc/product/printed_chassis/slicing_toolchain.dag b/dag/gunbc/product/printed_chassis/slicing_toolchain.dag index 0d5bb58ea31..b6cd99d7c23 100644 --- a/dag/gunbc/product/printed_chassis/slicing_toolchain.dag +++ b/dag/gunbc/product/printed_chassis/slicing_toolchain.dag @@ -12,10 +12,7 @@ import extdeps.printing.orca_slicer { orca_slice_to_project_command, OrcaExecutable, orca_version_probe_command, } -// Direct-host ABI and execution evidence. Historical refusal observations below retain their -// original hosts/environments. On 2026-10-03 slice_run executed the pinned archive successfully -// after the system packages supplied its runtime closure; with_verified_orca owns the private -// archive lifetime and prevents a caller's mutable input path becoming the executable authority. +// WHERE THE MESH-TO-MACHINE ROUTE STOPS TODAY, DECLARED RATHER THAN DISCOVERED AT RUN TIME. // // product.printed_chassis.export_profile ends at an STL. A Bambu Lab A1 mini in LAN Mode consumes a // sliced project, so exactly one step stands between a verified mesh and a printable artifact, and diff --git a/dag/std/human_intervention.dag b/dag/std/human_intervention.dag index 2d4e32b9ac0..261f43c3318 100644 --- a/dag/std/human_intervention.dag +++ b/dag/std/human_intervention.dag @@ -10,10 +10,13 @@ import std.algebra { Ordering, Less, Equal, Greater } // different costs, and summing them prices a one-time enrollment like a step paid on every // run. The axis below is what multiplies against device and provision counts, so it is the // axis the model carries. +// EveryOperatorRequest is a separately initiated, attended operation (such as a print), +// never a provisioning retry or scheduled convergence. Its count scales with operator requests. type HumanInterventionFrequency = FleetOnce | DeviceOnce | EveryProvision + | EveryOperatorRequest | BreakGlassOnly type HumanInterventionIdentity = NonEmptyStr where brand("HumanInterventionIdentity") @@ -51,10 +54,11 @@ type ProvisioningHumanBurden { fn human_intervention_frequency_eq(a: HumanInterventionFrequency, b: HumanInterventionFrequency) -> Bool { match a { - FleetOnce => match b { FleetOnce => true DeviceOnce => false EveryProvision => false BreakGlassOnly => false } - DeviceOnce => match b { FleetOnce => false DeviceOnce => true EveryProvision => false BreakGlassOnly => false } - EveryProvision => match b { FleetOnce => false DeviceOnce => false EveryProvision => true BreakGlassOnly => false } - BreakGlassOnly => match b { FleetOnce => false DeviceOnce => false EveryProvision => false BreakGlassOnly => true } + EveryOperatorRequest => match b { EveryOperatorRequest => true _ => false } + FleetOnce => match b { FleetOnce => true DeviceOnce => false EveryProvision => false BreakGlassOnly => false EveryOperatorRequest => false } + DeviceOnce => match b { FleetOnce => false DeviceOnce => true EveryProvision => false BreakGlassOnly => false EveryOperatorRequest => false } + EveryProvision => match b { FleetOnce => false DeviceOnce => false EveryProvision => true BreakGlassOnly => false EveryOperatorRequest => false } + BreakGlassOnly => match b { FleetOnce => false DeviceOnce => false EveryProvision => false BreakGlassOnly => true EveryOperatorRequest => false } } } @@ -77,6 +81,8 @@ fn human_intervention_count_at( // FleetOnce once ever. BreakGlassOnly is deliberately NOT a term — an emergency path is // not cheaper for being rare, it is a different kind of thing, and pricing it as routine // would let a strategy buy a better score by moving work into the emergency path. +// EveryOperatorRequest is likewise outside provisioning burden: it scales with separately +// initiated operator work, not fleet size or provision attempts. fn compare_int_ascending(a: Int, b: Int) -> Ordering { if a < b { Less } else if a > b { Greater } else { Equal } } diff --git a/dag/test/claim/review_sheet_converge_witness_test.dag b/dag/test/claim/review_sheet_converge_witness_test.dag index 91729676b2a..1fe1cf3ad6e 100644 --- a/dag/test/claim/review_sheet_converge_witness_test.dag +++ b/dag/test/claim/review_sheet_converge_witness_test.dag @@ -126,7 +126,7 @@ import std.human_intervention { HumanInterventionIdentity, FleetOnce, DeviceOnce, - EveryProvision, + EveryProvision, EveryOperatorRequest, BreakGlassOnly, NoDischargeSiteModeled, DischargedAt, @@ -2019,6 +2019,7 @@ test fn undeclared_spreadsheet_is_fleet_once_create_not_a_caller_string() -> Boo && match i.frequency { FleetOnce => true DeviceOnce => false + EveryOperatorRequest => false EveryProvision => false BreakGlassOnly => false } @@ -2052,11 +2053,13 @@ test fn sharing_is_device_once_editor_with_the_declared_writer() -> Bool { && match spreadsheet_share_intervention(spreadsheet_id: id, writer: w_fixture_writer()).frequency { DeviceOnce => true FleetOnce => false - EveryProvision => false + EveryOperatorRequest => false + EveryProvision => false BreakGlassOnly => false } FleetOnce => false - EveryProvision => false + EveryOperatorRequest => false + EveryProvision => false BreakGlassOnly => false } ReviewSheetOperatorRefused { reason: _ } => false @@ -2174,7 +2177,8 @@ test fn iam_grant_is_fleet_once_per_project_not_per_api() -> Bool { pattern: "fleet-automation", ) DeviceOnce => false - EveryProvision => false + EveryOperatorRequest => false + EveryProvision => false BreakGlassOnly => false } } diff --git a/docs/plans/printed-chassis-program.md b/docs/plans/printed-chassis-program.md index 52d7fb055ca..b0e3efd6fd6 100644 --- a/docs/plans/printed-chassis-program.md +++ b/docs/plans/printed-chassis-program.md @@ -1,263 +1,6 @@ # Printed node chassis — program plan -**Pinned plan. Progress reconciled 2026-10-03.** Printers are in use; the operator has the -printed standoff gauge in hand. The operator reports that the existing gauge aligns with numbered holes 1 and 3. - -## Current progress — 2026-10-03 - -Evidence: merged PRINT-N commit `b242faf7655` (#10680), current printed-chassis modules, -and the operator's 2026-10-03 report. Historical execution results are not tests rerun today. -This dated status supersedes the pre-arrival implementation narrative below. - -- **CAD bootstrap and measured export profile landed.** PRINT-N records CadQuery 2.8.0 running - through user-owned micromamba/conda-forge and measured tessellation observations in - `product.printed_chassis.export_profile`. The old kernel blocker is resolved. Full STEP/3MF - solid-readback acceptance is not established by that fact and remains to be audited. -- **Physical printing reached.** PRINT-N records printer-02 reporting `RUNNING` on the gauge - project after FTPS delivery and MQTT start through the modeled path. The operator now confirms - the gauge is in hand. No authenticated printer operation has been performed in this session. -- **Coupon feedback recorded.** `hole_fit_measurement.operator_ladder_reading_2026_09_04` - records M3 interference / tight / free at 3.00 / 3.10 / 3.20 mm nominal rungs. Printer attribution - remains absent; this is not universal hole compensation or qualification of both machines. -- **Initial pair fit observed, 2026-10-03.** The operator reports that both gauge holes align - perfectly with numbered holes **1 and 3**, with the gauge underneath the motherboard and no - screws installed. The supplied annotated image identifies these as **F and C**. This is an - operator-reported alignment pass on that pair; it does not establish loaded support, exact - tolerances, the other holes or the full FIT milestone. One board is sufficient for this check. - -### Numbered reference and observed pair - -The repo already cited the operator's Drive image in -`extdeps.boards.asrock_rack.asrock_altrad8ud_standoff_figure_authority`. A byte-for-byte local copy -is now [ALTRAD8UD_standoff_grid.png](../extdeps/asrock_rack/ALTRAD8UD_standoff_grid.png), retrieved -2026-10-03 from [the original Drive file](https://drive.google.com/file/d/1z36tmUWAwOwXWy6ASmaPWJNzKoZggBs8/view). -Use the image's numbers in operator instructions. Its annotated decimal coordinates are a visual -reference; they do not replace the standard's exact coordinate authority. - -| Image hole | Model location | -|---|---| -| 1 | F | -| 2 | M | -| 3 | C | -| 4 | H | -| 5 | L | -| 6 | B | -| 7 | R | - -**Observed:** 1–3 (F–C) aligns simultaneously underneath the board, without fasteners, per operator. -**Next candidate:** 3–4 (C–H). Other prepared checks are 4–5, 5–2, 3–6 and 4–7. -No new photo is required to identify the pair already reported against this reference. - -### Prepared follow-on experiments — 2026-10-03 - -`product.printed_chassis.fit_gauges` selects five additional gauges using the existing standard -location authority, gauge admission and CadQuery emitter. They are spacing experiments, not confirmed -mounting locations or qualified structural parts. Start with C–H after the existing F–C check passes; -the others extend coverage to the remaining inferred holes. Each is a separate part so it can be -identified by its file and labeled before leaving the bed. - -| Pair | Question answered | -|---|---| -| C–H | Does the rear-to-middle spacing agree on the C/H/L column? | -| H–L | Does the middle-to-front spacing agree on that column? | -| L–M | Does the front-row spacing agree? | -| C–B | Does the additional rear hole agree? | -| H–R | Does the outboard middle hole agree? | - -J remains excluded and S remains unobserved. A connected set of pair distances does not fix every -relative angle; the assembled adjustable fixture still must confirm the full map. Do not install -extra standoffs merely because the standard has a named location. - -**Measurements to gather before a mounting fixture:** - -- Identify the seven candidate holes (F, C, B, H, R, L, M) on one physical board; record any absent - or unexpected hole and pair mismatch. No need to disassemble several boards for this step. -- Actual standoff thread, height from support to board underside, and contact-pad/shoulder diameter; - identify the screws being used and their usable engagement. Do not infer these from the M3 coupon. -- Highest underside protrusion and clearance around each support; note solder joints/components - near the front overhang where a possible support would touch. -- Board edge dimensions and access around rear I/O, memory latches and power/storage connectors. - -**Measurements that can follow, before cassette geometry:** cooler model and total installed height; -PSU model, actual envelope and mounting points; fan model and intended fasteners; intended drives and -expansion cards; loaded node mass; cable connector bodies and the space needed to plug/unplug them; -available deployment width/depth/height. Missing values stay open instead of becoming defaults. The current fleet inventory identifies Dynatron W1 coolers on srv3 -and srv4; that does not identify the cooler on the board being checked, and its catalog row supplies -no measured installed envelope. - -**Recovered operator setup reports:** Creality PLA, white on printer-01 and black on printer-02, -reported September 4–5; on October 3 the operator says both setups are identical. These reports are -retained in `gunbc.fleet.printer_setup_reports`, with source timestamps and a read-only `main` -projection. The historical AMS report did not name a printer. The operator explicitly confirmed AMS physical -slot 1 on both machines on October 3; that now governs the print command. -Nozzle/plate settings still need reconciliation against retained execution evidence. Recovered files disagree: `print-n/work/gauge_petg.3mf` specifies PETG with -textured PEI; `printn-slice/gauge_container.3mf` specifies PLA with a cool plate. Preset metadata does not supersede the operator’s filament reports. Do not reuse their machine G-code blindly. Final slicing must also check -placement including brim/skirt: the longest narrow gauge nearly spans the 180 mm bed. Keep model -scale at 100%; resolve any placement conflict through explicit orientation/adhesion settings, not -by shrinking a measuring instrument. The existing fan-mount experiment -is optional after the actual fan and fasteners are identified; it does not gate motherboard FIT. - -### Preparation results — 2026-10-03 - -Generated **C–B, C–H, H–L, L–M and H–R** as individual STL and STEP files under -`/home/briansrls/print-prep-2026-10-03`. Generation ran through `fit_gauges.batch` with the -compiler built from this worktree; the generated Python contains the modeled geometry and export -settings. `geometry-readback.json` records hashes and executed results for all five parts: -valid single solids, expected dimensions and cylindrical hole locations/radii, STEP round-trip -agreement, closed manifold meshes, mesh bounds and volume agreement. These checks establish file -conformance for these gauges only, not physical fit or printer/material qualification. - -The three `test.claim.fit_gauges_witness_test` checks executed and passed: every candidate fits -the actual modeled printer envelope; none requests J/S; an unknown pair cannot emit a substitute. -`witnesses.log` retains the run. No physical observation has been inferred from those software tests; the 1–3 fit is separately -recorded from the operator above. - -**Ready for final slicing, not yet machine-ready G-code:** use C–H as the next candidate after the -existing F–C result. Use the recovered PLA reports; reconcile nozzle, plate and adhesion settings before -slicing. No upload or printer start was performed. Authenticated delivery/start was not revalidated; -the old `printer_credential_migration_run` includes destructive secret migration and is not an -appropriate routine readiness probe. A subsequent print must use an admitted credential path rather -than rerun that migration merely because it previously started a job. - -### Requested two-printer batch — 2026-10-03 - -The operator requests concurrent printing on both printers. Prepare **printer-01: holes 3–4 -(C–H)** and **printer-02: holes 4–5 (H–L)**. This checks two additional board spans; because the -parts differ, it is not a printer-to-printer calibration comparison. Source-identical STL copies and -hashes are staged under `/home/briansrls/print-prep-2026-10-03/two-printer-batch/`, one directory per -printer. Material/colour reports are now modeled: Creality PLA, white on printer-01 and black on -printer-02. Physical spool identity and current bed-clear state are not established by those reports. -The selected slicing profile is the A1 mini 0.4 mm nozzle, Generic PLA and Textured PEI plate; -this is a recorded process choice, not a claim of current printer telemetry. -The routine workload-identity entry point is implemented in the migration below; its deployment -remains pending. The operator-token batch route and live progress are recorded separately below. -No printer start is implied by staging the files. Label the finished gauges 3–4 and 4–5 before removing them. - -### Printer authentication migration to the ntfy workflow - -The operator requested migration on 2026-10-03. `gunbc.fleet.printer_federation_provision` -adds a dedicated `printer-lan` reader to the existing ntfy-approved GCP IAM estate. It requests -access only to the two rostered printer secrets; the print worker cannot create, rotate or delete -credentials or change IAM policy. The provider pins the repository, main workflow/ref, dispatch -and `printer-lan` environment through the shared OIDC claim authority. - -`fleet-converge` mode `printer` has two operations: - -1. `observe` resolves a printer's existing credential once and writes its exact version identity - to a receipt. The receipt contains no access code. It performs no printer action. -2. `print` requires the selected printer, staged project path on srv1, expected SHA-256, numeric - credential version and an explicit clear-bed report. It hashes a private copy before upload, - uses the same version for FTPS and MQTT, and refuses a workflow rerun. A successful receipt - means **start published**, not a claim that the machine reported RUNNING. Unknown outcomes - require observation, never automatic replay. - -The dedicated job runs on srv1, checks out the dispatch event SHA even if `expected_revision` is -supplied, and serializes starts per printer without cancelling an in-flight operation. It uses -`WorkloadIdentityToken`; no operator-token fallback and no destructive credential migration. -`gunbc.fleet.printer_host_tools.converge` is administrator preparation for the MQTT tool, using -the existing apt ensure and the tool's declared package. The worker checks the actual client -version before any upload. Mosquitto identifies its version through the documented `--help` -interface ([upstream manual](https://mosquitto.org/man/mosquitto_pub-1.html)). - -**Deployment standing:** source changes are not evidence of installed IAM grants or a dispatched -print. The main-pinned workflow must land before it can federate. The existing IAM bootstrap must -cover the new target resources so its observer can plan and its approved executor can apply those -resource-local bindings. Then run the shared `gcp_iam_converge` workflow and approve its concrete -diff through ntfy, prepare the srv1 MQTT tool, and run credential observation. Final sliced projects -and live printer-state verification remain required before dispatching these gauges. The operator -reported both beds clear during this session; do not carry that report to an unrelated later print. - -Validation: all seven printer workflow binding, admission, tool-version, OIDC, IAM approval and -start-replay regression tests passed. The canonical artifact gate regenerated the workflow; YAML -structure assertions and shell syntax checks passed. These are source checks, not live deployment -or printer observations. - -### Operator-token batch execution — 2026-10-03 - -The operator supplied a one-hour GCP token and asked to print before the migration lands. For this -batch, `printer_operator_approval.run` uses the existing `OperatorSuppliedToken` capability and the -shared signed ntfy approval gate. It does not relabel that token as WIF, invent an Actions run, or -change IAM/secret versions. The permanent main-pinned GitHub route remains separate. - -The approval binds printer, project SHA-256, exact credential version and batch attempt. After the -live approval it subscribes to a fresh report and requires IDLE/FINISH with zero print error, then -creates a persistent per-printer start marker before upload/start. A refused or ambiguous start is -never automatically replayed. `printer_report` reads telemetry through the same fleet identity, -exact credential and pinned CA; its report is current-state evidence, not causal acknowledgement. - -Printer-01 credential version 1 was read successfully through the modeled client. Holes 3–4 -were sliced successfully by Orca 2.4.2, with ZIP and embedded G-code checksum checks, one plate, -identity scaling, 15 layers/3 mm height, and bounds including the brim within 180 mm. Selected -settings: PLA, 220 °C nozzle, 65 °C textured bed, 0.20 mm layers, 2 mm outer brim. Estimated -19m22s and 6.99 g. Project SHA-256: -`28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636af701580e`. - -The isolated srv1 execution directory is `/home/briansrls/print-run-2026-10-03`; credentials live -only in owner-only private staging files and are not repository artifacts. Holes 4–5 also sliced -and passed the same checks: 15 layers, 13m48s, 3.67 g, SHA-256 -`a8f434e80014b6a316d30665b372bd0df3e858a68a2ff04a5fe4f50031d68535`. - -The shared approval client now follows the declared writer cutover to the approval broker on -8085; the old roadmap endpoint refused writes as designed. Both requests were filed and reached -approval polling. Four operator-route witnesses passed, including all three writer postures. -Fresh read-only reports showed printer-01 IDLE/error 0 and printer-02 IDLE/error 83902511. -Both reported PLA in AMS tray 0 (physical slot 1), while the existing start command selects the -external spool. The initial executions were stopped before upload to resolve that mismatch. -No start marker, physical start, or RUNNING observation was claimed at that point. - -### Local preparation environment - -The isolated worktree is `/home/briansrls/.worktrees/gunbc/3d-printing-2026-10-03`. -Local preparation artifacts are in `/home/briansrls/print-prep-2026-10-03`: - -- `venv/`: CadQuery 2.8.0; resolved dependency versions in `requirements.lock`. -- `target/debug/gunbc`: compiler built from this worktree. Invocation requires an enforced cgroup - memory budget; the preparation run uses a systemd scope with `MemoryMax=24G` and no swap. -- `mounting-check-map.svg`: schematic from the checked-out standard-location rows; all physical - correspondences remain subject to the bench check. -- `verify-generated-gauges.py`: reads the emitted literal geometry instructions, runs them in - CadQuery, checks the resulting solid and STEP round-trip dimensions/hole axes/volume, and checks - STL bounds, closed manifold edges and volume. Its result only concerns generated-file conformance. -- `slicer-probe/DO_NOT_PRINT_old_settings.3mf`: tool-recovery probe using the old project settings; - deliberately excluded from the next-print candidates. - -The preserved OrcaSlicer 2.4.2 AppImage at `/tmp/orca.AppImage` matches the digest recorded in -`extdeps.printing.orca_slicer`. Its extracted CLI at `/tmp/orcax/squashfs-root/AppRun` now launches -after installing its missing OpenGL/WebKitGTK runtime dependencies. `--help` and model inspection -work. The modeled probe now uses the supported `--help` interface. `with_verified_orca` copies -and hashes the pinned AppImage in a private realization, runs probe and slice inside its callback, -and removes the realization before returning. `slice_run.run` uses it to produce the gauge project; -its success establishes slicing, not an observed physical print. - -### Gauge observations to collect today - -Record each board separately: stable label, model/revision, selected holes and gauge orientation. -The gauge checks **LocF–LocC**, modeled as offsets of **157.48 mm along the rear edge and -22.86 mm in depth**. These are model values, not measured dimensions of the printed part. -Establish the physical hole correspondence before interpreting a result; say whether checking board -holes directly or installed standoffs, since the latter also tests standoff placement. - -Report whether both locations align simultaneously with the gauge resting naturally; if not, -which end aligns and the direction of the miss. Note obstructions or warp. Do not count alignment -obtained by bending the gauge or drawing it into place with screws as a pass. Record measured -values only when actually measured, with the instrument used. A photo can supplement the result. -An F–C pass supports that pair on that board, not the full mounting map or underside clearance. - -### Next steps and dependencies - -| Order | Work | Dependency / completion evidence | -|---|---|---| -| 1 | Record each board's F–C result | Operator observations; retain disagreements and distinguish board-hole alignment from standoff fit | -| 2 | Resolve misses; extend successful checks to remaining mounting locations | For a miss, distinguish orientation, printed-gauge dimensions and board/standoff placement before changing geometry. A pass permits additional pair gauges or an adjustable fixture, not freezing the entire map | -| 3 | Audit CAD, slicing and print evidence | Can proceed during board checks: locate generated artifacts, profile/toolchain identity, printer/material/nozzle binding and outstanding solid-readback controls; close PRINT-7/8 only against their full criteria | -| 4 | Complete process observations | Attribute the existing coupon only from retained evidence; otherwise print labeled replacements. Observe both printer/spool combinations independently and repeat attempts before claiming repeatability | -| 5 | Build an unpowered adjustable fit fixture | Confirm remaining mounting positions, standoff thread/height, underside keep-outs and connector access. PLA can serve this experiment without qualifying structural or powered use | -| 6 | Measure cassette inputs and choose joints/material | Can start now: actual cooler and PSU envelopes, node mass, drive requirement, fasteners, connector access and cable service space. Joint family and structural material precede their qualification prints | -| 7 | Prototype one removable cassette and one fixed bay | Requires fit and structural-process evidence; then PSU/cables, airflow and powered thermal validation under standing requirements, followed by 2x2 service demonstration | - -The immediate milestone is a verified mounting map and an unpowered fit fixture. Full cassette -geometry depends on measured hardware envelopes and structural-process qualification. - +**Pinned plan. Updated 2026-09-02.** Printers land **2026-09-03**. Governing statement: @@ -288,8 +31,8 @@ whether more printers are worth buying. | Fan form factor | 80 mm; **count derived**, not assumed | operator + derivation | | Rack unit | 2x2 block, composable | side-chat ruling | | Service law | fixed frame, removable cassette | operator requirement R9 | -| PSU placement | shared 12 V supply system on fixed frame, one per four-node block | operator acceptance, 4 October 2026; supersedes cassette-resident PSU | -| External bundle | 2 x Ethernet + 1 x protected, isolatable DC feed per node; AC at shared supply | operator acceptance, 4 October 2026 | +| PSU placement | cassette-resident | side-chat ruling | +| External bundle | 2 x Ethernet + 1 x AC per node | operator | ## Standing laws @@ -315,13 +58,13 @@ because a step without one cannot be said to be done. | **PRINT-3** | CAL | Coupon authority: ladder as modeled experimental design, rungs and plate derived | **done** | | **PRINT-4** | TOOLCHAIN | Realization contract v0: contract derives its own rungs; a wrong-step handler is caught and located | **done** | | **PRINT-5** | TOOLCHAIN | Raw transport schema admission: unknown version, missing field and unknown operation all refuse before any geometry call | **done** | -| **PRINT-6** | TOOLCHAIN | CadQuery handler + authority wall; sealed instruction plan; walls 1-3 execute, wall 4 (solid readback) is a declared boundary | handler/export profile landed; full readback closure unverified | -| **PRINT-7** | TOOLCHAIN | STEP/3MF emitted and re-inspected; output conformance re-establishes envelope, holes, walls | kernel blocker resolved; full acceptance to verify | -| **PRINT-8** | TOOLCHAIN | Slicer profile bound or refused; per-printer manifests; ProcessQualificationIdentity minted, not branded | live printing exercised; qualification closure to verify | -| **PRINT-9** | CAL | Coupons printed on BOTH printers; each printer+spool admitted or refused **independently** | coupon feedback recorded; independent qualification and repeatability pending | -| **PRINT-10** | FIT | Adjustable-standoff fixture; real board mounted unpowered; hole map measured back and frozen | gauge in hand; 1–3 (F–C) alignment pass reported; full fixture/map open | +| **PRINT-6** | TOOLCHAIN | CadQuery handler + authority wall; sealed instruction plan; walls 1-3 execute, wall 4 (solid readback) is a declared boundary | handler done; materializer + export profile open | +| **PRINT-7** | TOOLCHAIN | STEP/3MF emitted and re-inspected; output conformance re-establishes envelope, holes, walls | | +| **PRINT-8** | TOOLCHAIN | Slicer profile bound or refused; per-printer manifests; ProcessQualificationIdentity minted, not branded | | +| **PRINT-9** | CAL | Coupons printed on BOTH printers; each printer+spool admitted or refused **independently** | needs printers | +| **PRINT-10** | FIT | Adjustable-standoff fixture; real board mounted unpowered; hole map measured back and frozen | needs printers + board | | **PRINT-11** | CASSETTE | Structural cassette: rails, tray, handle, latch; carries node mass; no seam in layer-separation tension | needs PETG decision | -| **PRINT-12** | CASSETTE | Shared fixed-frame PSU/distribution and node harnesses; every connector insertable; nothing side-loaded; **PE continuous with board, standoffs and cassette all removed** | needs PSU envelope | +| **PRINT-12** | CASSETTE | PSU carrier and harnesses; every connector insertable; nothing side-loaded; **PE continuous with board, standoffs and cassette all removed** | needs PSU envelope | | **PRINT-13** | CASSETTE | 80 mm fan carrier + replaceable duct; powered thermal admitted against a bench baseline | needs cooler choice | | **PRINT-14** | RACK | One fixed bay; cassette retained in service, removable after disconnect; empty bay structurally complete | | | **PRINT-15** | RACK | Management-node mount (Raspberry-Pi class) on the rack, carried as a bay peer rather than an accessory | | @@ -349,18 +92,23 @@ nominal. The check was run because the module's own annotation asserted "changin moves that witness", and an annotation asserting a property the code does not have is exactly what #10119 had to repair four times. -PRINT-5 and PRINT-6 landed with #10119. PRINT-N (#10680) subsequently resolved CAD bootstrap, -added measured export-profile work, and exercised physical printing. PRINT-7/8 still require an -evidence audit against their full criteria; job-start evidence alone does not close them. +PRINT-5 and PRINT-6 landed with #10119, so the pre-arrival critical path is now **PRINT-7 alone**, +and it is BLOCKED rather than merely pending: re-inspecting an emitted STEP/3MF needs a CAD kernel, +and the trigger for that is a base image with glibc >= 2.38 or an x86_64 runner. PRINT-8 is the first +step that needs the hardware itself, and it is not blocked by PRINT-7 — the coupon can be printed and +measured before any kernel readback exists, it just cannot issue an absolute process-qualification +receipt until PRINT-7 closes. ### What blocks what -- **CAD bootstrap is resolved.** Full output-conformance and process-qualification evidence - remain to be reconciled. +- **PRINT-5..6 are landed.** PRINT-7 is BLOCKED on a CAD kernel — measured, not assumed: CadQuery + installs on this arm64 container and clears `libGL.so.1`, then `casadi` requires `GLIBCXX_3.4.32` + which requires `GLIBC_2.38`, and this box is glibc 2.36. The trigger is a base image or an x86_64 + runner, NOT a pip install. - **Joint family** (dovetail / tongue-and-groove / keyed slide / pin) gates the ProductionInterfaceCoupon only — a later half of PRINT-8, not the MachineProcessCoupon. -- **Structural material qualification** gates durable/powered cassette work. PLA can carry CAL - and unpowered FIT; no PLA-to-PETG receipt carry. +- **PETG** gates PRINT-10 onward. PLA carries PRINT-8 and PRINT-9 and stops there: no PLA-to-PETG + receipt carry. - **Deployment envelope** gates PRINT-13..14 layout admission, nothing earlier. - **Drive count** gates PRINT-11 completion only. @@ -369,9 +117,9 @@ evidence audit against their full criteria; job-start evidence alone does not cl | Project | Scope | Terminal evidence | State | |---|---|---|---| | **CONTRACT** | Goals, non-goals, authority graph, refusal law, MVP boundary | No assumption represented as a default | deferred — no consumer yet | -| **MEASURE** | Board, cooler, PSU, fan, DIMM, cable, **deployment envelope**, uncertainty | Pack sufficient to generate CAL+FIT | **partial — coupon readings recorded; board-fit observations pending** | -| **TOOLCHAIN** | Realization contract, CadQuery handler, authority wall, STEP/3MF, slicer env, printer nodes | Deterministic generation + executed no-parallel-authority controls | partial — CAD/export and live printing exercised; full conformance open | -| **CAL+FIT** | Coupons, qualified tolerances, adjustable standoff fixture, real-board fit, hole-map feedback | One unpowered board fits; hole authority admitted | in progress — coupon feedback and gauge in hand | +| **MEASURE** | Board, cooler, PSU, fan, DIMM, cable, **deployment envelope**, uncertainty | Pack sufficient to generate CAL+FIT | **partial — carrier landed, roster empty** | +| **TOOLCHAIN** | Realization contract, CadQuery handler, authority wall, STEP/3MF, slicer env, printer nodes | Deterministic generation + executed no-parallel-authority controls | not started | +| **CAL+FIT** | Coupons, qualified tolerances, adjustable standoff fixture, real-board fit, hole-map feedback | One unpowered board fits; hole authority admitted | not started | | **CASSETTE** | Structure -> PSU/cables -> airflow -> powered thermal | Removable node operates and is serviceable | not started | | **RACK+VERDICT** | One bay -> 2x2 block -> scale/economics | Middle-node service proven; buy-more-printers verdict | not started | @@ -608,7 +356,7 @@ unwalled**: a `CommitRequired` artifact whose path matches a developer ignore pa uncommittable. Its trigger is the first such artifact; walling it before one exists would be a check whose RED is unauthorable. -**Historical pre-PRINT-N state; the measured export profile subsequently landed.** Export was removed from the emitted program rather than profiled. The +**Export is removed from the emitted program rather than profiled.** The `cq.exporters.export(result, "…stl")` line chose five things the model never stated — STL over STEP or 3MF, a filename, a working-directory-relative destination, format-by-extension, and CadQuery's default tessellation policy. The last is not neutral: STL is a mesh format, so every modeled circle @@ -634,7 +382,7 @@ on the grounds that it preserves a typed place for the next obligation, but noth gate required emptiness — so the forcing did not exist, and the discharged property is carried where it executes, in the type of the accepting arm. -**Historical bootstrap probe, superseded by PRINT-N conda-forge bootstrap.** Wall 4 — re-reading the produced *solid* +**Toolchain boundary, now measured rather than assumed.** Wall 4 — re-reading the produced *solid* for plate dimensions, datum location and the eleven ladder holes — is a §4b boundary obligation, not a rung. Its trigger was written as "a runner with cadquery importable", which was too vague to act on, so it was probed to termination: @@ -648,7 +396,7 @@ on, so it was probed to termination: - Past that, `casadi` requires **`GLIBCXX_3.4.32`**, and a libstdc++ carrying it requires **`GLIBC_2.38`**. This container is **glibc 2.36**. -The probe then incorrectly concluded the trigger was **a base image with glibc ≥ 2.38 (or an x86_64 runner), not a pip install** — +So the real trigger is **a base image with glibc ≥ 2.38 (or an x86_64 runner), not a pip install** — and assembling one by hand-extracting `.deb`s onto `LD_LIBRARY_PATH` is where a probe turns into the workaround §5 names, so it was stopped there rather than pushed through. The environment wall 4 needs is a *pinned, identified* toolchain — Python version, CadQuery version, OCP closure, image digest, @@ -744,18 +492,20 @@ So the PETG decision blocks the **durable/powered CASSETTE path**, not the first unpowered FIT fixture. Qualify each printer-spool combination independently; never infer that an observed difference is the printer alone. -## Open decisions (operator) — structural and rack work +## Open decisions (operator) — none block the first print -- **Drive count** — a CASSETTE completion obligation. Does not block gauge checks or the unpowered fixture. +- **Drive count** — a CASSETTE completion obligation. Blocks nothing before Thursday. - **PETG** — blocks structural/powered parts, not CAL or unpowered FIT. - **Deployment envelope** — **not on the pre-arrival critical path.** Its absence must refuse rack-layout admission later, not block calibration now. ## Next -Follow the dated next-step table above. Board-specific gauge observations and remaining mounting -checks lead to the unpowered fixture; toolchain-evidence reconciliation and cassette measurements -can proceed independently. The former list predated the landed PRINT-N implementation. +1. ~~`CalibrationCouponAuthority`~~ — landed as `product.printed_chassis.coupon`. +2. `RealizationContract v0` — the transport-only, schema-bound feature graph the handler consumes. +3. CadQuery handler + four-part wall executed over v0, with all four negative controls. +4. STEP/3MF generation and conformance; bound slicer profile or refusal. +5. Per-printer manifests for node 1 and node 2; measurement/admission form ready for results. **Deferred as definition-only residue until each has a consumer:** `CONTRACT` (needs the generator), `DeploymentEnvelope` (needs the layout-comparison consumer). The design of both is pinned above. @@ -1051,303 +801,3 @@ node pitch (which decides upright versus flat-on-riser, and the riser makes it a aux power connector type and sustained draw against the node supply and stack distribution · whether a mixed stack is allowed at all, since a uniform GPU stack and a mixed stack have different sidewall answers. - -### Batch follow-up: AMS and printer-02 storage - -On 2026-10-03 the operator confirmed AMS physical slot 1 on both printers and identified -printer-02 error 83902511 (0x0500402f) as a microSD error. The operator was unsure whether -that card was in use. This LAN route uploads to SD storage and starts a `file:///sdcard/` -project, so the error remains a start blocker; it is not suppressed. No formatting or deletion -of printer storage is authorized or performed. The start payload now selects AMS and maps -the single project filament to tray 0; the signed operator request includes this feed choice. - -The operator subsequently reported formatting printer-02’s card locally. A fresh no-error -report is still required before its start. Remote formatting was requested as a future capability; -the current modeled workflow has no format operation, and support on these printers is unverified. - -At 22:01 UTC printer-02's AMS request was approved. Its fresh report admitted IDLE/error 0, -but FTPS upload returned curl exit 25 / server 550 before MQTT start. Its durable batch marker -is retained; there is no automatic replay. Printer-01's approved attempt received a partial -temperature-only report and correctly refused before upload. The observer now allows up to -eight fresh messages to obtain state and error together, without inventing missing fields. - -The operator confirmed that printer-02 recovered and appeared functional after formatting. -A separate after-format attempt retains the original failed upload marker and requests new -approval. No MQTT start had been sent in the failed attempt. Status observation now subscribes -before publishing a read-only `pushing.pushall` snapshot request with Mosquitto request/response; -it still requires an explicit complete state/error report and cleans the private credential files. - -Correction at 22:12 UTC: the operator reports printer-02 still shows SD card “error” in -settings despite clearing print_error. Stop its after-format attempt before actuation and -prioritize printer-01. A zero print_error is not proof of usable storage. - -### Printer-01 start observed by operator - -At approximately 22:15 UTC the full-snapshot gate admitted printer-01 and its holes 3–4 -project uploaded successfully. MQTT publication did not return PUBACK. The operator then -confirmed the machine was heating or calibrating. The publisher had already exited with -connection-lost code 7 and cleaned its credential/CA directories before termination was attempted. -The transport outcome is UNKNOWN; the batch marker is retained and no start replay is permitted. -This is operator-observed preparation, not yet a telemetry-confirmed extrusion or completed print. -The selected feed is AMS physical slot 1; slice estimate 19m22s. Printer-02 remains held for -SD-card settings error, despite its zero print_error report. - -At 22:19 UTC a fresh authenticated snapshot confirmed printer-01 `RUNNING`, `print_error=0`, -and subtask name `gunbc-28a8700489cf908edb9ed59c0aa871fe6903b4d558c3b64de6a7636af701580e.3mf`. -It reported 4%, 18 minutes remaining, layer 0/15 (preparation), nozzle 176 °C and bed 62 °C. -This reconciles the unknown MQTT transport result with observed execution of the intended gauge; -it does not establish completed extrusion or a finished part. No duplicate start was sent. - -### Printer-02 recovery retry - -The operator subsequently reported printer-02's card working and requested another attempt, -supplying a fresh one-hour token. The previously stopped after-format attempt had no start claim -and had sent no printer command. At approximately 22:31 UTC its new signed approval was observed, -the fresh full-state check admitted the printer, and the holes 4–5 file uploaded successfully. -AMS physical slot 1 remains selected. MQTT publication is awaiting reconciliation; it is not -replayed. The earlier FTP-550 attempt and its claim remain separate and retained. - -At approximately 22:36 UTC, fresh telemetry confirmed printer-02 RUNNING/error 0 with the -expected `gunbc-a8f434e80014b6a316d30665b372bd0df3e858a68a2ff04a5fe4f50031d68535.3mf` -subtask, 38%, layer 0/15, and 8 minutes remaining. The upload/start route now has physical -execution evidence after the card recovery; this is not a long-term card-health qualification. - -The operator reported printer-01 finished the holes 3–4 gauge, both holes line up well, -and the part was removed / bed cleared. Record C–H as an operator-reported alignment pass, -without inferring load-bearing qualification. Prepare holes 3–6 (C–B) as printer-01's next gauge. - -The operator authorized the next printer-01 print after confirming C–H fit and a clear bed. -Holes 3–6 (C–B) sliced through the verified Orca entry with the same selected profiles. -Readback: SHA-256 `fb6d4bd1fcb23c07087b5575e6f5b20619f7ab8da03217828148d84cfcefc09f`, -15 layers / 3 mm, identity scaling, one plate, valid embedded G-code MD5 and ZIP integrity, -brim bounds [57.211684, 80.071684, 122.788316, 99.928316] mm, 12m10s / 2.59 g PLA. -The slicer metadata carries `bed_temperature_too_high_than_filament` at the upstream Generic PLA -textured-plate setting of 65 °C; this setting matches the successfully printed/aligned prior gauge. -It is retained as an observed warning, not described as warning-free. The new request binds this -project digest and a separate holes-3-6 attempt; printer-02's running job is not touched. - -The other remaining candidates are also sliced and validated under -`/home/briansrls/print-prep-2026-10-03/next-batch/` (no start requested for either yet): - -| Gauge | SHA-256 | Estimate | PLA | -|---|---|---|---| -| 5–2 (L–M) | `607601f18602ae273472654c01b9d34ca1674b7f6f5356c7136a382af41f545b` | 19m34s | 7.10 g | -| 4–7 (H–R) | `972ec6278c3334e070d4bd31884ce781211454c1c32296652e5f9a6ae4269252` | 13m26s | 3.42 g | - -Both passed ZIP/G-code checksum, one-plate, 15-layer/3-mm, identity-transform, A1 mini/PLA/0.4-mm -profile, and 180-mm bed-bound checks. L–M including brim spans X=1.331684–178.668316 mm; no -scaling was applied. Both retain the same 65 °C bed-temperature warning described above. - -### Operator direction: stop the pair-gauge sequence - -The operator requested stopping further gauge printing and moving toward chassis/rack parts. -Do not enqueue the prepared 5–2 or 4–7 gauges. A fresh snapshot already confirmed the newly -started printer-01 3–6 job RUNNING/error 0 with its exact digest filename and 12 minutes remaining; -the operator explicitly chose to let that active print finish. No cancellation is requested. -The operator expects the remaining positions to be accurate; retain that as an expectation, not -a measured fit result. 1–3 and 3–4 have reported alignment passes. - -Repository inspection found no printable full chassis/cassette/rack-bay geometry. Existing concrete -geometry covers coupons, standoff gauges and a fan-mount plate; rack_mount models occupancy of -catalog hardware, not printable rack construction. The proposed next geometry is a segmented, -adjustable motherboard carrier for unpowered assembly/fit, forming the first chassis prototype. -Remaining mounting positions can stay adjustable rather than requiring more pair gauges. Actual -standoff thread/height and underside clearance still determine the board-support interface; -PSU/cooler envelopes, structural material and load/joint checks remain for the powered cassette. - -### Hosted concept workbench - -The operator accepted the requirements and requested a hosted site for iterating on the chassis, -including independent 3D rotation of each piece. Published privately: -[Altra Chassis Workbench](https://altra-chassis-workbench.briansrls448156.chatgpt.site). -It contains an explicitly provisional four-panel carrier, segmented rails, handle and retaining -tabs, with a 2×2 fixed-frame study. Individual isolation/orbit, explosion, withdrawal and design -controls are available. There are no print controls or manufacturing exports. -The missing hardware dimensions remain unknown; the geometry is a visual proposal, not an -admitted fabrication result. Source project details are recorded in `sites/README.md`. -Desktop/mobile rendering and the principal interactions were exercised successfully. -All temporary copies of the operator's GCP token were removed after printing work ended. - -### Cassette hardware and airflow study — 4 October 2026 - -Operator clarification: the target cooler is Dynatron (W1 in the preceding question), and PSUs -must be interchangeable rather than tied to one model. Roughly 400–600 W was an example range, -not a finalized electrical qualification. PSU compatibility must include accepted physical envelope, -mounting adapters, connector requirements, intake/exhaust clearance and service access; wattage -alone is insufficient. The inventory's unread PSU identities remain unread. - -Concept 02 adds a rear cassette-resident adjustable metal PSU cradle, a variable PSU envelope, -Dynatron W1 geometry, DIMM clearance zones, one to three front 80 mm fans with individual carrier -feet, side airflow guides, a schematic internal harness corridor and front-to-rear direction arrows. -The operator proposed cassette-mounted fans. All these components travel with the cassette in the -2×2 frame view. Three fans is a layout default, not an established cooling requirement. - -The [Dynatron manufacturer page](https://www.dynatron.co/product-page/w1), checked 4 October, -publishes active dimensions 141.1 × 84 × 72 mm and passive dimensions 116 × 84 × 72 mm. -The study depicts the active envelope with simplified fins/fan; active versus passive operation, -actual socket position/orientation and installed offset are not established by that depiction. -The P8 PWM PST uses its catalog 80 × 80 × 25 mm envelope; the existing 71.5 mm hole pitch and -4.4 mm clearance remain convention-unverified. Fan count, ducting and header/PST-chain electrical -admission still require evidence. Airflow arrows are not simulation results. - -PSU slider defaults (150 × 85 × 150 mm), DIMM zones, the 50 mm rear service gap, adjustable cradle -interfaces and harness route are design proposals. They are not measured hardware or a claim that -all 400–600 W PSUs fit. No drive or expansion-card configuration has been invented. A height check -flags simple study-pitch conflicts; it does not prove full assembly clearance or powered suitability. - -Source commit: `8c944389c0dbcb25639d4c6069587863ae0d0fd5` in the separate Sites repository. -Browser checks passed for selectable parts, PSU isolation, fan-count changes, height-conflict -feedback, airflow visibility, orbit, rack withdrawal, requirements and mobile overflow. -No additional gauge or chassis print was started. - -### Shared power accepted — supersedes cassette-resident PSU - -On 4 October 2026 the operator accepted shared power per four-node block. This replaces the earlier -cassette-resident PSU requirement and its per-node AC bundle. Eight nodes comprise two independently -powered four-node blocks. Each block has a fixed-frame 12 V supply system and four separately -protected, isolatable and disconnectable node feeds. Cassettes retain their board, cooler and fans. -The earlier roughly 400–600 W example concerned individual supplies and is not a shared-block rating. - -[ASRock manual §2.8](https://download.asrock.com/Manual/ALTRAD8UD-1L2T.pdf) documents direct +12 V -DC input and distinguishes it from the ATX signal-adapter connection. This establishes a supported -input mode, not a qualified multi-node distribution system. Required power connections, independent -startup/shutdown behaviour, aggregate load/startup demand, branch fault protection, wire/connector -ratings, voltage drop, protective-earth continuity and service isolation still require engineering. -No connector pinout, fuse size, PSU wattage, redundancy or live hot-swap capability is selected here. -A nonredundant shared supply is a four-node failure domain; redundancy remains an open decision. - -Concept 03 depicts one fixed top power shelf, one adjustable shared PSU space reservation, one -four-branch distribution placeholder and stationary branch harnesses. A DC disconnect placeholder -travels with each cassette. Withdrawal illustrates the node shut down and its branch isolated and -disconnected first. Top-shelf placement and all distribution dimensions are visual proposals. Shared -supply height is additional to the two node rows; cassette pitch is governed by cassette hardware. -Older Concept 02 notes above are retained as history and are superseded for power topology. - -Concept 03 source: `7f77b6884f23af034fd1632e5918c3a21e988e5a` in the Sites repository. -Browser checks passed: shared PSU isolation, no PSU on the cassette-only view, fan-count controls, -height feedback, airflow visibility, rack withdrawal, requirements and mobile layout. - -### Rear exhaust and first fan-mount print — 4 October 2026 - -The operator requested moving fans to the rear to keep hands at the front handle away from them, -and authorized modeling/printing a first part. Rear cassette-mounted exhaust is the new layout; -front-to-rear airflow remains. Rear I/O/cable access and a finger guard remain unresolved. Both -beds were reported clear; printer-01 is selected with the previously confirmed PLA in AMS slot 1. - -The first real part reuses `fan_mount.arctic_p8_fan_mount`: 92 × 92 × 4 mm plate, 76 mm aperture, -71.5 mm hole pitch and 4.4 mm mounting holes. This is a useful fan mounting plate for unpowered -assembly fit, not another motherboard spacing gauge and not a complete cassette. Cassette -attachment and guarding have not been fabricated. Hole-pattern evidence remains convention-unverified. -`fan_mount_print.main` carries the existing admitted geometry through the existing CadQuery emitter -and exercised STL export settings; there are no independently authored CAD coordinates. - -Generation and verification artifacts are in `/home/briansrls/print-prep-2026-10-04/rear-fan-mount`. -CadQuery 2.8.0 checks passed for one valid solid, dimensions, all five hole axes/radii, analytic volume, -STEP round-trip and a closed manifold STL. STL SHA-256: -`2b0c7df7e8cf79dc772a33ee6f62a6ccf9e5568ef07e0309545313480e35b4f8`. -Orca 2.4.2 sliced through `slice_run.run` at 0.20 mm, 20 layers, PLA/0.4 mm nozzle, 220 °C nozzle, -65 °C textured bed and 2 mm outer brim. Estimate 28m41s, 11.40 g. ZIP/G-code checksum, one plate, -identity scaling and 180 mm bed bounds passed; bounds including brim are 42.071684–137.928316 mm -on both axes. The retained bed-temperature warning matches the previously exercised 65 °C setting. -The slice_info first-layer-time metadata is malformed; G-code estimates 6m4s and that metadata is -not an admission input. Project SHA-256: -`71da943ff2ed73a6b47c54759ba288e82af7d084c269bf9a413d4d901cf5199c`. - -Concept 04 is published with rear fans and downloadable generated STL/STEP, source -`c2a49273da70f20a23e2e044fe552dceb74b773f`. Browser interaction checks passed. -The operator-token/ntfy route is being used because PR #13149 remains unmerged. The request binds -printer-01, credential version 1, exact project digest and attempt `rear-fan-mount-20261004-01`. -A start result must be observed separately; preparation is not evidence of printing. - -The first launch refused before submission because its submission-key environment variable was -missing; it filed no approval and created no start marker. Corrected operator launches on srv1 -require these existing key paths (never their contents) plus the private token-file path: - -- `GUNBC_APPROVAL_SUBMISSION_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-submission-mac-key` -- `GUNBC_APPROVAL_CAPABILITY_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-mac-key` -- `GUNBC_APPROVAL_STORE_RECEIPT_MAC_KEY_FILE=/etc/gunbc-roadmap/approval-store-receipt-mac-key` -- `RUNNER_TEMP` pointing to an owner-only staging directory. - -The refusal log is retained as `print-01-preflight-refusal.log` under the srv1 run directory. -Retrying the same attempt before any request/start marker exists does not replay a printer start. - -The corrected run passed the signed approval gate, observed the printer ready, claimed its durable -start marker, uploaded the exact project and sent one AMS-slot-1 start. MQTT publication lost its -connection without acknowledgement after two minutes, so the effect was classified UNKNOWN and -was not replayed. A separate modeled `printer_report.run` observation then returned RUNNING, -print_error 0, exact digest filename `gunbc-71da943ff2ed73a6b47c54759ba288e82af7d084c269bf9a413d4d901cf5199c.3mf`, -19% and 23 minutes remaining. This confirms the requested job active; layer_num was 0, so it is -not yet evidence of deposited layers or completed fit. The snapshot is retained locally and on srv1. -The operator independently reported probably heating. Printer-02 was not started. Temporary copies -of the operator token were deleted from both hosts. - -### Complete logical cassette review — 4 October 2026 - -The operator reports that the printed rear fan plate fits. Record this as a physical fit -observation, not a caliper measurement or a promotion of the upstream conventional hole pattern. -The operator requests the whole cassette model now, review of all pieces together, then the first -cassette print. Stop the individual gauge sequence. No printer start is authorized by this review -artifact generation alone; the next print follows assembly review and the existing print workflow. - -Review 05 replaces browser-authored mechanical geometry with `product.printed_chassis.cassette` -and `rack_assembly`. `assembly_geometry` carries typed box/cylinder CSG, micrometre coordinates, -part kind, fixed/moving scope, print orientation and mating-interface records. `cassette_review` -emits the full manifest. `review_realization` emits the generic CadQuery host adapter; it chooses -no mechanical dimensions and grants no fabrication admission. The old Site `concept.js` is removed. -Vendor board outline, fan plate/pattern, W1 envelope and P8 depth resolve from their existing owners. - -One cassette has 49 printed instances / 29 distinct authored geometries: four vented tray panels, -four panel splices, four rails, two rail splices, a handle, two retaining stops, two rear extension -arms, two fan crossmember halves and a splice, three fitted-pattern fan plates, three feet, three -guards, two side airflow guides, four guide spacers and twelve guard spacers. The tray is -271.840 × 294.700 mm. Each piece fits the 180 mm A1 mini envelope in its declared orientation. -The two-level block retains a 140 mm bay pitch; this is a custom frame, not a claim of standard -2U/4U rack compliance. The fixed metal frame includes runner proposals. Shared 12 V supply, -four protected/isolatable feeds and distribution space remain fixed when a cassette is extracted. -Supply dimensions are explicitly space reservations; no supply wattage or circuit is selected. - -The guides initially intersected the rail flanges and outer fan feet. The corrected model raises -the guide foot to 13 mm, adds front 8 mm / rear 4 mm spacers, and drills the outer foot for the shared -rear bolt stack. The front rests on the panel; the rear rests on the outer fan foot. This clears -the rail flange by 1 mm. Guard pads enclose the four fan fastener holes; upper 7 mm / lower 2 mm -spacers account for the 5 mm fan foot. Guard protection, fan bolt length and engagement still need -assembly review. All 56 distinct modeled fastener axes have a coaxial mate in another printed part. -This is geometric alignment evidence, not a fastener strength or engagement test. - -Reproduction (from this worktree; use a memory-limited scope for the compiler): - -```sh -systemd-run --user --scope -p MemoryMax=24G -p MemorySwapMax=0 gunbc run \ - --source-root dag --source-root src/v2 \ - --entry dag/gunbc/product/printed_chassis/cassette_review.dag --function main > model.json -systemd-run --user --scope -p MemoryMax=24G -p MemorySwapMax=0 gunbc run \ - --source-root dag --source-root src/v2 \ - --entry dag/gunbc/product/printed_chassis/review_realization.dag --function main > realize-review.py -python realize-review.py model.json fresh-output-directory -``` - -Use the exercised CadQuery 2.8.0 environment. Output includes bed-oriented STL for each printed -instance, local-coordinate STEP, cassette assembly STEP, viewer meshes, a manifest with digests, -parts/print-quantity/interface CSVs and one review ZIP. Generated artifacts and run logs are retained -under `/home/briansrls/print-prep-2026-10-04/cassette-r05`; the Site carries the review package. -The adapter refuses invalid/disconnected printed solids, cuts missing material, out-of-bed parts, -nonmanifold STL, STEP/STL bounds or volume divergence, unmatched fastener bores, orphan interface -members and intersections between printed parts. Kernel bounding boxes explicitly ignore cached -triangulation, while STL bounds allow the modeled tessellation deflection. - -Remaining interfaces are deliberately represented as unresolved: board support thread/height and -underside keep-outs, loaded rail/joint/material behavior, real cooler pose and rear cable access, -fastener engagement, selected power/distribution hardware, thermal performance and unspecified -storage/expansion. No invented standoffs are provided. The first cassette is an unpowered assembly -fit, not a populated operational chassis. Review the complete parts and these interfaces together; -do not restart a gauge-by-gauge process. - -Final exercised generation passed for all 49 printed pieces, 29 unique geometries, 56 matched -fastener axes and zero printed-part intersections. Negative controls separately refused an oversized -part, a disconnected solid, an unknown primitive, a displaced mating bore and an orphan interface. -The emitted adapter was the code actually executed. Local browser checks passed with the final -package; the Site shows the same generated mesh geometry as the downloadable CAD. No slicing, -approval request or printer start was performed in this review step. Reuse the already fitted fan -plate when scheduling quantities after review. - -Review 05 published successfully at the existing private workbench, Site source `a9658fd05d27e83220c611b5fb00ac4397b74739`. diff --git a/docs/plans/printer-workflow-review.md b/docs/plans/printer-workflow-review.md new file mode 100644 index 00000000000..2d61155a295 --- /dev/null +++ b/docs/plans/printer-workflow-review.md @@ -0,0 +1,26 @@ +# Printer workflow review status + +The printer start paths share one approval and claim implementation. Dispatch input validation +is not authorization: the common path verifies an unexpired ntfy grant, observes an explicit +error-free IDLE report, and acquires a durable claim for the approved escalation ID before any +upload/start. FINISH, partial reports and errors refuse. Approval text requires the operator to +check the actual bed when approving. A second IDLE observation and approval-expiry check occur +immediately before publishing the start. + +Claims live in `/var/lib/gunbc/printer-starts` on the separately modeled printer LAN host. The +host check precedes the gate. The service account needs an owner-controlled, persistent directory +there before this path can operate; missing/unwritable storage refuses. Do not delete a claim to +retry an uncertain start. This review has not provisioned that directory or performed a live print. + +One exact-version credential session covers preflight, upload, post-upload observation and start. +It is evidence of an existing-secret read, never PrinterCredentialProvisioned. Receipt source is +projected from the selected token source. The observed setup report is informational only. + +The census distinguishes separately initiated operator requests from unattended provisioning. +Physical print starts select operator approval. Current code-enforced approval under a federated +read and the operator-token compatibility entry have explicit, bounded divergences; neither is +presented as a broker-minted printer credential. Token-file input does not establish credential +custody and is not authorization to relay tokens through chat. + +Local approval, census and workflow-binding witnesses pass. CI is required and the PR remains +draft. CAD and slicer preparation are split out; approval-client cutover is prerequisite #13218. From 4aa8963df5d7c36667e61a3676fa599a06886969 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 05:21:27 +0000 Subject: [PATCH 21/24] Regenerate printer workflow with its required release artifact shape --- .github/workflows/fleet-converge.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index b406effb6c5..c96e844ac15 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -154,7 +154,7 @@ jobs: - name: Derive release-bins key (pre-materialization axes; sha256 at cross-run scope) id: release_bins_key run: | - if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ] || [ "${FLEET_CONVERGE_MODE:-}" = 'printer' ]; then + if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ]; then set -e ROOT=$('git' 'rev-parse' '--show-toplevel') DIRTY=$('git' '-C' "$ROOT" 'status' '--porcelain' '--untracked-files=no') @@ -360,7 +360,7 @@ jobs: - name: Decide release-bins outcome; verify a stored pack completely before any restored binary runs id: release_bins_outcome run: | - if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ] || [ "${FLEET_CONVERGE_MODE:-}" = 'printer' ]; then + if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ]; then set -e ROOT=$('git' 'rev-parse' '--show-toplevel') STAGE="$ROOT"'/.release-bins-staging' @@ -496,7 +496,7 @@ jobs: - name: Build release bins (the dispatched mode's product) id: release_build run: | - if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ] || [ "${FLEET_CONVERGE_MODE:-}" = 'printer' ]; then + if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ]; then cargo build --release -p v1-compiler --bin claim_executor --bin gunbc else ROOT=$('git' 'rev-parse' '--show-toplevel') @@ -531,7 +531,7 @@ jobs: timeout-minutes: 5 - name: Pack release bins with member manifest into staging (build outcomes only; tar keeps exec bits) run: | - if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ] || [ "${FLEET_CONVERGE_MODE:-}" = 'printer' ]; then + if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ]; then set -e ROOT=$('git' 'rev-parse' '--show-toplevel') STAGE="$ROOT"'/.release-bins-staging' @@ -602,7 +602,7 @@ jobs: - name: Verify the handed-off release bins against the build key, then promote and run --verify-build-artifacts (fail-closed) id: release_bins run: | - if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ] || [ "${FLEET_CONVERGE_MODE:-}" = 'printer' ]; then + if [ "${FLEET_CONVERGE_MODE:-}" = 'approval_device_enrolment_code_issue' ]; then set -e ROOT=$('git' 'rev-parse' '--show-toplevel') '[' '-n' "$RELEASE_BINS_KEY" ']' || ('echo' '::error title=release-bins ConsumerKeyMissing::the build job published no release-bins key'; exit 1) From 6da087d2e30f789c59adf11287fe179fc41b0276 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 10:26:22 +0000 Subject: [PATCH 22/24] Import printer setup-report algebra providers explicitly --- dag/gunbc/fleet/printer_setup_reports.dag | 1 + 1 file changed, 1 insertion(+) diff --git a/dag/gunbc/fleet/printer_setup_reports.dag b/dag/gunbc/fleet/printer_setup_reports.dag index bfcec9230f9..0e150b3c57c 100644 --- a/dag/gunbc/fleet/printer_setup_reports.dag +++ b/dag/gunbc/fleet/printer_setup_reports.dag @@ -1,4 +1,5 @@ module gunbc.fleet.printer_setup_reports +import v2.std.algebra {filter,length} import std.types { String, NonEmptyStr, List } import extdeps.printing.fdm { FilamentMaterial, Pla } From 54bd96e7e9bb0c3b770af677b9e81bc88e89e7f5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 13:36:31 +0000 Subject: [PATCH 23/24] Use vendor job readiness and classify spurious Bambu 0500C011 --- .../printing/bambu_lab_lan_protocol.dag | 11 +++++++++++ dag/gunbc/fleet/printer_operator_approval.dag | 11 +++++++---- .../printer_operator_approval_witness_test.dag | 18 +++++++++++------- docs/plans/printer-workflow-review.md | 15 ++++++++++++--- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/dag/extdeps/printing/bambu_lab_lan_protocol.dag b/dag/extdeps/printing/bambu_lab_lan_protocol.dag index 869e2227be3..8e48fd4a258 100644 --- a/dag/extdeps/printing/bambu_lab_lan_protocol.dag +++ b/dag/extdeps/printing/bambu_lab_lan_protocol.dag @@ -213,3 +213,14 @@ fn bambu_status_snapshot_payload() -> String { json_kv(key: "push_target", value: json_int(n: 1)), ]))])) } + +// Bambu Studio MachineObject::can_print accepts IDLE and FINISH; FINISH is +// excluded from is_in_printing_status. This is job readiness, never bed-clear evidence. +// https://github.com/bambulab/BambuStudio/blob/master/src/slic3r/GUI/DeviceManager.cpp +fn bambu_completed_or_idle_state(state:String) -> Bool {state=="IDLE"||state=="FINISH"} + +// 83935249 is 0x0500C011. Bambu's collaborator calls this a non-existent error +// that should be deleted; the vendor error table has a blank description for it. +// https://github.com/bambulab/BambuStudio/issues/4495#issuecomment-2275068828 +// This exact wire value is classified, not all nonzero codes or all blank UI messages. +fn bambu_start_error_nonblocking(code:String) -> Bool {code=="0"||code=="83935249"} diff --git a/dag/gunbc/fleet/printer_operator_approval.dag b/dag/gunbc/fleet/printer_operator_approval.dag index 1e2bebc3f94..ecd3889fcdc 100644 --- a/dag/gunbc/fleet/printer_operator_approval.dag +++ b/dag/gunbc/fleet/printer_operator_approval.dag @@ -1,5 +1,6 @@ module gunbc.fleet.printer_operator_approval +import extdeps.printing.bambu_lab_lan_protocol {bambu_completed_or_idle_state,bambu_start_error_nonblocking} import extdeps.tools.hostname import std.algebra {trim} import gunbc.fleet.printer_federation {printer_lan_host_identity} @@ -114,7 +115,7 @@ fn approve_and_print(printer: String, path: String, digest: String, version: Str Filesystem.Write(path: concat(start_claim_path(escalation_id:request.escalation_id),".before-start.json"), content: text).success && report_allows_start(text: text) _ => false } - if !idle { return exit_failure(reason: "Printer was not freshly observed IDLE with no print error; nothing uploaded or started") } + if !idle { return exit_failure(reason: "Printer was not freshly observed IDLE/FINISH without a blocking print error; nothing uploaded or started") } let claim = Filesystem.WriteCreateNew(path: start_claim_path(escalation_id:request.escalation_id), content: request.subject) if !claim.success { exit_failure(reason: "This printer's batch start is already claimed or its durable marker cannot be written; do not replay") } else { print_claimed_project(claim:ClaimedPrinterStart {printer:p,path:path,digest:digest,version:version as NonEmptyStr,token_source:token_source,session:session,authorization:authorization,request:request}) } @@ -128,16 +129,18 @@ fn approve_and_print(printer: String, path: String, digest: String, version: Str } } +// Called only behind current, job-bound approval of a physically cleared bed. +// Neither IDLE nor FINISH establishes bed clearance; the approval owns that evidence. fn report_allows_start(text: String) -> Bool { match parse_json_document(s: text) { JsonDocumentParsed { value: root } => match json_object_unique_member(v: root, key: "print") { JsonMemberFound { value: p } => { let idle = match json_object_unique_member(v: p, key: "gcode_state") { - JsonMemberFound { value: JsonString { value: state } } => state == "IDLE" + JsonMemberFound { value: JsonString { value: state } } => bambu_completed_or_idle_state(state:state) _ => false } let clear = match json_object_unique_member(v: p, key: "print_error") { - JsonMemberFound { value: JsonNumber { lexeme: code } } => (code as String) == "0" + JsonMemberFound { value: JsonNumber { lexeme: code } } => bambu_start_error_nonblocking(code:code as String) _ => false } idle && clear @@ -180,7 +183,7 @@ fn print_claimed_project(claim:ClaimedPrinterStart) -> ProcessExit { ProjectDelivered { receipt: receipt } => { let remote = receipt_remote_name(r: receipt) let still_idle=match report_using_session(session:claim.session) { ProcessOutputPresent {text:t}=>report_allows_start(text:t) _=>false } - if !still_idle { return exit_failure(reason:"Printer no longer IDLE after upload; start withheld and claim retained") } + if !still_idle { return exit_failure(reason:"Printer no longer ready after upload; start withheld and claim retained") } let fresh_authority=match approval_instant_now() { Absent=>false Present {value:now}=>match authorize(authorization:claim.authorization,request:claim.request,observed_at:now) {AuthorizationPermitted {grant:_}=>true _=>false} diff --git a/dag/test/claim/printer_operator_approval_witness_test.dag b/dag/test/claim/printer_operator_approval_witness_test.dag index 5d1a9b372d6..77cd9900d84 100644 --- a/dag/test/claim/printer_operator_approval_witness_test.dag +++ b/dag/test/claim/printer_operator_approval_witness_test.dag @@ -30,14 +30,18 @@ test fn approval_changes_with_device_bytes_version_or_attempt() -> Bool { } } -test fn only_explicit_idle_without_print_error_admits_start() -> Bool { - report_allows_start(text: "{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":0}}") && - !report_allows_start(text: "{\"print\":{\"gcode_state\":\"FINISH\",\"print_error\":0}}") && - !report_allows_start(text: "{\"print\":{\"gcode_state\":\"RUNNING\",\"print_error\":0}}") && - !report_allows_start(text: "{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":1}}") && - !report_allows_start(text: "{\"print\":{\"gcode_state\":\"IDLE\"}}") && - !report_allows_start(text: "{}") +test fn completed_or_idle_job_needs_nonblocking_error() -> Bool { + all(["IDLE","FINISH"],state=>all(["0","83935249"],code=>report_allows_start(text:join(["{\"print\":{\"gcode_state\":\"",state,"\",\"print_error\":",code,"}}"],"")))) && + all(["RUNNING","PAUSE","PREPARE","SLICING","FAILED","UNKNOWN",""],state=>all(["0","83935249"],code=>!report_allows_start(text:join(["{\"print\":{\"gcode_state\":\"",state,"\",\"print_error\":",code,"}}"],"")))) } +test fn unknown_errors_and_incomplete_reports_remain_refused() -> Bool { + all(["1","83902511","83935248","83935250"],code=>!report_allows_start(text:join(["{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":",code,"}}"],""))) && + !report_allows_start(text:"{\"print\":{\"gcode_state\":\"IDLE\"}}") && + !report_allows_start(text:"{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":\"0\"}}") && + !report_allows_start(text:"{\"print\":{\"gcode_state\":\"IDLE\",\"print_error\":0,\"print_error\":83902511}}") && + !report_allows_start(text:"{}") +} + test fn help_exit_one_needs_the_actual_supported_version_banner() -> Bool { diff --git a/docs/plans/printer-workflow-review.md b/docs/plans/printer-workflow-review.md index 2d61155a295..4c28bdf57ec 100644 --- a/docs/plans/printer-workflow-review.md +++ b/docs/plans/printer-workflow-review.md @@ -2,9 +2,9 @@ The printer start paths share one approval and claim implementation. Dispatch input validation is not authorization: the common path verifies an unexpired ntfy grant, observes an explicit -error-free IDLE report, and acquires a durable claim for the approved escalation ID before any -upload/start. FINISH, partial reports and errors refuse. Approval text requires the operator to -check the actual bed when approving. A second IDLE observation and approval-expiry check occur +IDLE or FINISH report with zero or the vendor-confirmed spurious 0500C011 code, and acquires a durable claim for the approved escalation ID before any +upload/start. Active/paused/unknown states, partial reports and other error codes refuse. Approval text requires the operator to +check the actual bed when approving. A second readiness observation and approval-expiry check occur immediately before publishing the start. Claims live in `/var/lib/gunbc/printer-starts` on the separately modeled printer LAN host. The @@ -24,3 +24,12 @@ custody and is not authorization to relay tokens through chat. Local approval, census and workflow-binding witnesses pass. CI is required and the PR remains draft. CAD and slicer preparation are split out; approval-client cutover is prerequisite #13218. + +Vendor-readiness correction (2026-10-04): Bambu Studio treats FINISH as a completed +job eligible for another print, not a cooling state. The bed may still hold a part in +either IDLE or FINISH, so fresh job-bound bed-clear approval remains mandatory. +The exact 0500C011 code is vendor-confirmed non-existent (BambuStudio issue 4495, +comment 2275068828); no broader nonzero-error exemption is introduced. Tests retain +refusal of the actual prior SD fault 83902511, adjacent codes, active/paused states, +missing/duplicate fields and string-typed errors. The durable claim and post-upload +readiness/approval-expiry checks remain unchanged. From a96a2fd8b59ac5d51de0ad28c71e5152e36bc7d6 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 4 Oct 2026 22:11:03 +0000 Subject: [PATCH 24/24] Reconcile printer starts automatically against exact-job telemetry --- dag/gunbc/fleet/printer_job_start.dag | 6 +- dag/gunbc/fleet/printer_operator_approval.dag | 10 +-- dag/gunbc/fleet/printer_report.dag | 10 ++- .../fleet/printer_start_reconciliation.dag | 84 +++++++++++++++++++ ...nter_start_reconciliation_witness_test.dag | 27 ++++++ docs/plans/printer-workflow-review.md | 31 +++++++ 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 dag/gunbc/fleet/printer_start_reconciliation.dag create mode 100644 dag/test/claim/printer_start_reconciliation_witness_test.dag diff --git a/dag/gunbc/fleet/printer_job_start.dag b/dag/gunbc/fleet/printer_job_start.dag index 021e3140454..238d2a88a15 100644 --- a/dag/gunbc/fleet/printer_job_start.dag +++ b/dag/gunbc/fleet/printer_job_start.dag @@ -1,6 +1,8 @@ module gunbc.fleet.printer_job_start import gunbc.fleet_printer_access {PrinterCredentialSession,session_printer,session_credential} +import extdeps.tools.gnu_coreutils {timeout_command} +import std.measure {second} import std.types { NonEmptyStr, String, Bool, Secret, FilePath, List } import extdeps.shell import extdeps.exec.command { ArgvCommand, CommandTransport, LocalExec, env_prefixed_command } @@ -446,14 +448,14 @@ fn publish_start_with_credential( outcome = run_shell_command_capture( command: env_prefixed_command( bindings: mosquitto_options_env(binding: binding), - command: mosquitto_publish_command( + command: timeout_command(duration:second(count:20),kill_after:second(count:2),command:mosquitto_publish_command( endpoint: endpoint, topic: topic as NonEmptyStr, payload: bambu_start_payload_json( command: command, sequence_id: start_attempt_identity(workspace: workspace) as String), qos: bambu_start_command_qos, - ca_bundle: pinned_ca_bundle_path(bundle: bundle))), + ca_bundle: pinned_ca_bundle_path(bundle: bundle)))), transport: LocalExec ) let credential_shredded = shred_mosquitto_options_binding(binding: binding) diff --git a/dag/gunbc/fleet/printer_operator_approval.dag b/dag/gunbc/fleet/printer_operator_approval.dag index ecd3889fcdc..d2ef608433d 100644 --- a/dag/gunbc/fleet/printer_operator_approval.dag +++ b/dag/gunbc/fleet/printer_operator_approval.dag @@ -20,7 +20,7 @@ import extdeps.shell.exec { ProcessOutputPresent } import gunbc.fleet.printer_report { report_using_session } import gunbc.fleet_asset_identity { PhysicalAssetIdentity } import gunbc.fleet.printer_selection { select_printer, PrinterSelected, PrinterSelectionRefused, print_inputs_admitted } -import gunbc.fleet.printer_receipt {write_receipt} +import gunbc.fleet.printer_start_reconciliation {reconcile_start} import gunbc.fleet_printer_access {PrinterCredentialSession,read_existing_printer_session,PrinterSessionReady,PrinterSessionRefused} import gunbc.fleet.printer_project_delivery { PrinterProjectPayload, identify_project_payload, PayloadIdentified, PayloadIdentificationRefused, @@ -190,12 +190,12 @@ fn print_claimed_project(claim:ClaimedPrinterStart) -> ProcessExit { } if !fresh_authority { return exit_failure(reason:"Print approval expired or no longer matches after upload; claim retained") } match start_print_with_session(session:claim.session, remote_name:remote) { - PrintStartPublished { receipt: _ } => write_receipt(printer: printer, standing: "start-published; RUNNING not yet observed", version: version as String, remote: remote as String, digest: digest,token_source:token_source) + PrintStartPublished { receipt: _ } => reconcile_start(session:claim.session,remote:remote,version:version as String,digest:digest,token_source:token_source,evidence_prefix:start_claim_path(escalation_id:claim.request.escalation_id),publish_standing:"publish-acknowledged",cleanup_issue:"") PrintStartRefused { printer: _, cause: c } => exit_failure(reason: c as String) PrintStartRefusedResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Start refused; credential residue remains: ", c as String)) - PrintStartOutcomeUnknown { printer: _, cause: c } => exit_failure(reason: concat("Start outcome unknown; DO NOT REPLAY: ", c as String)) - PrintStartOutcomeUnknownResidueRemains { printer: _, cause: c } => exit_failure(reason: concat("Start outcome unknown with residue; DO NOT REPLAY: ", c as String)) - PrintStartPublishedCleanupRefused { receipt: _, cause: c } => exit_failure(reason: concat("Start published but cleanup refused; DO NOT REPLAY: ", c as String)) + PrintStartOutcomeUnknown { printer: _, cause: c } => reconcile_start(session:claim.session,remote:remote,version:version as String,digest:digest,token_source:token_source,evidence_prefix:start_claim_path(escalation_id:claim.request.escalation_id),publish_standing:c as String,cleanup_issue:"") + PrintStartOutcomeUnknownResidueRemains { printer: _, cause: c } => reconcile_start(session:claim.session,remote:remote,version:version as String,digest:digest,token_source:token_source,evidence_prefix:start_claim_path(escalation_id:claim.request.escalation_id),publish_standing:"publish-uncertain",cleanup_issue:c as String) + PrintStartPublishedCleanupRefused { receipt: _, cause: c } => reconcile_start(session:claim.session,remote:remote,version:version as String,digest:digest,token_source:token_source,evidence_prefix:start_claim_path(escalation_id:claim.request.escalation_id),publish_standing:"publish-acknowledged",cleanup_issue:c as String) } } ProjectDeliveryRefused { printer: _, cause: c } => exit_failure(reason: c as String) diff --git a/dag/gunbc/fleet/printer_report.dag b/dag/gunbc/fleet/printer_report.dag index 9e81cf11d1a..0ca99a09155 100644 --- a/dag/gunbc/fleet/printer_report.dag +++ b/dag/gunbc/fleet/printer_report.dag @@ -38,10 +38,18 @@ fn report(printer: PhysicalAssetIdentity, version: NonEmptyStr, token_source: Ac } } fn report_using_session(session:PrinterCredentialSession) -> ProcessOutcome { + report_session_attempts(session:session,remaining:8) +} + +// Reconciliation owns its retry budget; do not multiply it by the readiness retry loop. +fn report_once_using_session(session:PrinterCredentialSession) -> ProcessOutcome { + report_session_attempts(session:session,remaining:1) +} +fn report_session_attempts(session:PrinterCredentialSession,remaining:Int) -> ProcessOutcome { let printer=session_printer(s:session) match printer_serial(printer:printer) { PrinterSerialFound {serial:serial}=>match printer_endpoint_address(printer:printer) { - PrinterAddressFound {address:a}=>complete_report_with_credential(serial:serial,address:a,credential:session_credential(s:session),remaining:8) + PrinterAddressFound {address:a}=>if remaining==1 {report_with_credential(serial:serial,address:a,credential:session_credential(s:session))} else {complete_report_with_credential(serial:serial,address:a,credential:session_credential(s:session),remaining:remaining)} _=>ProcessRefused {exit_code:1,stderr:"Printer address absent or ambiguous"} } _=>ProcessRefused {exit_code:1,stderr:"Printer serial absent or ambiguous"} diff --git a/dag/gunbc/fleet/printer_start_reconciliation.dag b/dag/gunbc/fleet/printer_start_reconciliation.dag new file mode 100644 index 00000000000..27cd7f7e8b3 --- /dev/null +++ b/dag/gunbc/fleet/printer_start_reconciliation.dag @@ -0,0 +1,84 @@ +module gunbc.fleet.printer_start_reconciliation + +import std.types {String,NonEmptyStr,Bool,Int} +import std.process {ProcessExit,ExitSuccess,exit_failure} +import extdeps.tools.sleep +import extdeps.filesystem.filesystem_io {Filesystem} +import extdeps.languages.json.parse {parse_json_document,JsonDocumentParsed,json_object_unique_member,JsonMemberFound} +import extdeps.languages.json.emit {JsonString,JsonNumber,json_object,json_kv,json_string,serialize_json} +import extdeps.shell.exec {ProcessOutputPresent,ProcessOutputAbsent,ProcessRefused} +import extdeps.printing.bambu_lab_lan_protocol {bambu_start_error_nonblocking} +import gunbc.fleet_printer_access {PrinterCredentialSession,session_printer} +import gunbc.fleet.printer_report {report_once_using_session} +import gunbc.fleet.printer_receipt {write_receipt} +import gunbc.auth.access_token_source {AccessTokenSource} + +// Observing activity is not proof of application-level exactly-once delivery. The +// pre-start idle check and retained durable claim remain the actuation boundary. +type StartObservation = ExpectedProjectRunning | ExpectedProjectBlocked | StartUnresolved + +// Never merge partial deltas into a synthetic confirmation or trust a display name. +// State, digest-derived remote filename and error must coexist in one fresh report. +fn observe_start(text:String,remote:String) -> StartObservation { + match parse_json_document(s:text) { + JsonDocumentParsed {value:root}=>match json_object_unique_member(v:root,key:"print") { + JsonMemberFound {value:p}=>match json_object_unique_member(v:p,key:"gcode_file") { + JsonMemberFound {value:JsonString {value:file}}=> + if file!=remote || remote=="" {StartUnresolved} else { + match json_object_unique_member(v:p,key:"gcode_state") { + JsonMemberFound {value:JsonString {value:state}}=>match json_object_unique_member(v:p,key:"print_error") { + JsonMemberFound {value:JsonNumber {lexeme:code}}=> + if state=="FAILED" || state=="PAUSE" || !bambu_start_error_nonblocking(code:code as String) {ExpectedProjectBlocked} + else if state=="RUNNING" {ExpectedProjectRunning} else {StartUnresolved} + _=>StartUnresolved + } + _=>StartUnresolved + } + } + _=>StartUnresolved + } + _=>StartUnresolved + } + _=>StartUnresolved + } +} + +type ReconcileStep = ConfirmRunning | StopBlocked | StopUncertain | ReadAgain {remaining:Int} + +// Bounded retry is for read-only observations only. No branch can request a publish. +fn reconcile_step(observation:StartObservation,remaining:Int) -> ReconcileStep { + match observation { + ExpectedProjectRunning=>ConfirmRunning + ExpectedProjectBlocked=>StopBlocked + StartUnresolved=>if remaining<=1 {StopUncertain} else {ReadAgain {remaining:remaining-1}} + } +} + +// Persist every response beside the durable start claim. Eight single MQTT reads +// (15 s each) and at most seven 5 s delays bound the MQTT observation budget; CA and +// filesystem operations add their own runtime. Cleanup faults never become success. +fn reconcile_start(session:PrinterCredentialSession,remote:NonEmptyStr,version:String,digest:String,token_source:AccessTokenSource,evidence_prefix:String,publish_standing:String,cleanup_issue:String) -> ProcessExit { + reconcile_reads(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence_prefix:evidence_prefix,publish_standing:publish_standing,cleanup_issue:cleanup_issue,remaining:8) +} +fn reconcile_reads(session:PrinterCredentialSession,remote:NonEmptyStr,version:String,digest:String,token_source:AccessTokenSource,evidence_prefix:String,publish_standing:String,cleanup_issue:String,remaining:Int) -> ProcessExit { + let response=report_once_using_session(session:session) + let text=match response {ProcessOutputPresent {text:t}=>t ProcessOutputAbsent=>"" ProcessRefused {exit_code:_,stderr:e}=>serialize_json(v:json_object(members:[json_kv(key:"read_refused",value:json_string(s:e))]))} + let evidence=join([evidence_prefix,".after-start-",to_string(9-remaining),".json"],"") + if !Filesystem.Write(path:evidence,content:text).success {return exit_failure(reason:"Cannot retain post-start evidence; claim retained, DO NOT REPLAY")} + match reconcile_step(observation:observe_start(text:text,remote:remote as String),remaining:remaining) { + ReadAgain {remaining:n}=>{ + if !sleep.Delay.Seconds(seconds:"5").success {return finish_reconciliation(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence:evidence,publish_standing:publish_standing,cleanup_issue:cleanup_issue,outcome:"uncertain: observation delay failed",confirmed:false)} + reconcile_reads(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence_prefix:evidence_prefix,publish_standing:publish_standing,cleanup_issue:cleanup_issue,remaining:n) + } + ConfirmRunning=>finish_reconciliation(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence:evidence,publish_standing:publish_standing,cleanup_issue:cleanup_issue,outcome:"running-confirmed",confirmed:true) + StopBlocked=>finish_reconciliation(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence:evidence,publish_standing:publish_standing,cleanup_issue:cleanup_issue,outcome:"expected-project-blocked",confirmed:false) + StopUncertain=>finish_reconciliation(session:session,remote:remote,version:version,digest:digest,token_source:token_source,evidence:evidence,publish_standing:publish_standing,cleanup_issue:cleanup_issue,outcome:"uncertain: observation budget exhausted",confirmed:false) + } +} +fn finish_reconciliation(session:PrinterCredentialSession,remote:NonEmptyStr,version:String,digest:String,token_source:AccessTokenSource,evidence:String,publish_standing:String,cleanup_issue:String,outcome:String,confirmed:Bool) -> ProcessExit { + let standing=join([outcome,"; publisher: ",publish_standing,"; cleanup: ",cleanup_issue,"; evidence: ",evidence],"") + match write_receipt(printer:session_printer(s:session),standing:standing,version:version,remote:remote as String,digest:digest,token_source:token_source) { + ExitSuccess=>if confirmed && cleanup_issue=="" {ExitSuccess} else {exit_failure(reason:concat(standing,"; claim retained, DO NOT REPLAY"))} + other=>other + } +} diff --git a/dag/test/claim/printer_start_reconciliation_witness_test.dag b/dag/test/claim/printer_start_reconciliation_witness_test.dag new file mode 100644 index 00000000000..59ea70efd1a --- /dev/null +++ b/dag/test/claim/printer_start_reconciliation_witness_test.dag @@ -0,0 +1,27 @@ +module test.claim.printer_start_reconciliation_witness_test +import std.types {Bool,String} +import gunbc.fleet.printer_start_reconciliation {observe_start,reconcile_step,ExpectedProjectRunning,ExpectedProjectBlocked,StartUnresolved,ConfirmRunning,StopBlocked,StopUncertain,ReadAgain} + +fn report(state:String,file:String,code:String) -> String { + join(["{\"print\":{\"gcode_state\":\"",state,"\",\"gcode_file\":\"",file,"\",\"print_error\":",code,"}}"],"") +} +test fn exact_running_project_survives_spurious_vendor_error() -> Bool { + all(["0","83935249"],code=>match observe_start(text:report(state:"RUNNING",file:"gunbc-abc.3mf",code:code),remote:"gunbc-abc.3mf") {ExpectedProjectRunning=>true _=>false}) +} +test fn other_project_and_finished_previous_job_never_confirm() -> Bool { + all(["IDLE","FINISH","PREPARE","SLICING","UNKNOWN"],state=>match observe_start(text:report(state:state,file:"gunbc-abc.3mf",code:"0"),remote:"gunbc-abc.3mf") {StartUnresolved=>true _=>false}) && + match observe_start(text:report(state:"RUNNING",file:"gunbc-other.3mf",code:"0"),remote:"gunbc-abc.3mf") {StartUnresolved=>true _=>false} +} +test fn partial_duplicate_and_display_only_reports_cannot_confirm() -> Bool { + all(["{}","not json","{\"print\":{\"gcode_state\":\"RUNNING\",\"subtask_name\":\"gunbc-abc.3mf\",\"print_error\":0}}","{\"print\":{\"gcode_state\":\"RUNNING\",\"gcode_file\":\"gunbc-abc.3mf\"}}","{\"print\":{\"gcode_state\":\"RUNNING\",\"gcode_file\":\"gunbc-abc.3mf\",\"print_error\":0,\"print_error\":1}}"],text=>match observe_start(text:text,remote:"gunbc-abc.3mf") {StartUnresolved=>true _=>false}) +} +test fn expected_project_pause_failure_and_real_errors_are_distinct() -> Bool { + all(["PAUSE","FAILED"],state=>match observe_start(text:report(state:state,file:"gunbc-abc.3mf",code:"0"),remote:"gunbc-abc.3mf") {ExpectedProjectBlocked=>true _=>false}) && + match observe_start(text:report(state:"RUNNING",file:"gunbc-abc.3mf",code:"83902511"),remote:"gunbc-abc.3mf") {ExpectedProjectBlocked=>true _=>false} +} +test fn unresolved_budget_terminates_and_confirmation_stops_reads() -> Bool { + (match reconcile_step(observation:StartUnresolved,remaining:2) {ReadAgain {remaining:n}=>n==1 _=>false}) && + (match reconcile_step(observation:StartUnresolved,remaining:1) {StopUncertain=>true _=>false}) && + (match reconcile_step(observation:ExpectedProjectRunning,remaining:8) {ConfirmRunning=>true _=>false}) && + (match reconcile_step(observation:ExpectedProjectBlocked,remaining:8) {StopBlocked=>true _=>false}) +} diff --git a/docs/plans/printer-workflow-review.md b/docs/plans/printer-workflow-review.md index 4c28bdf57ec..ff3692b9d9c 100644 --- a/docs/plans/printer-workflow-review.md +++ b/docs/plans/printer-workflow-review.md @@ -33,3 +33,34 @@ comment 2275068828); no broader nonzero-error exemption is introduced. Tests ret refusal of the actual prior SD fault 83902511, adjacent codes, active/paused states, missing/duplicate fields and string-typed errors. The durable claim and post-upload readiness/approval-expiry checks remain unchanged. + +## Automatic post-start reconciliation + +Both the Actions and operator routes now enter the same read-only reconciliation after +an acknowledged or uncertain publish. The publisher is bounded by GNU timeout at 20 seconds +with a 2-second kill grace; every nonzero outcome remains uncertain, never permission to +republish. The cause of the observed connection drops is not established or claimed fixed. + +Reconciliation reuses the credential session and makes at most eight fresh single-response +MQTT observations, each with the existing 15-second wait, separated by 5 seconds. It retains +each raw response (including incomplete responses) beside the durable escalation claim. +A report confirms running only if it contains the exact digest-derived `gcode_file`, +`gcode_state: RUNNING`, and a nonblocking numeric `print_error` in the same JSON object. +No display-name fallback, cached report, merged delta, FINISH or unknown state can confirm. +The same project's pause/failure/blocking error stops reconciliation; wrong-file and partial +reports can consume the observation budget but never trigger another start. + +The receipt records running-confirmed, expected-project-blocked or uncertain, the publisher +outcome, cleanup problems and the retained evidence path. Only confirmed running with no +cleanup failure returns success. Failure/uncertainty retains the claim and forbids replay. +The MQTT wait budget excludes CA acquisition and local filesystem runtime. This change +handles acknowledgement loss without manual telemetry checks; it does not promise that the +LAN or firmware cannot fail or that MQTT provides application-level exactly-once printing. + +The two wave-three jobs were confirmed through the previous manual report route before this +change: both exact digest filenames RUNNING. No physical start was issued to test this change. + +Validation for this change: five reconciliation witnesses, six approval/readiness witnesses, +and 24 publisher/no-replay witnesses passed locally. Both shared entry paths typecheck through +the common approval implementation. Live execution of the new post-start path remains for the +next authorized print; the current jobs used the previous revision.