unaltraweb 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +26 -4
  3. data/docs/_documentation/en/40-distribution.md +33 -10
  4. data/docs/_documentation/en/43-workspace-path-policies.md +13 -0
  5. data/docs/_documentation/en/47-private-practice-pdfs.md +130 -0
  6. data/docs/agents/consolidation-0.7.2.md +97 -0
  7. data/docs/agents/d0-runtime-identity.md +8 -0
  8. data/docs/agents/deployment-integration-0.7.1.md +4 -0
  9. data/docs/agents/manual-authoring-components.md +23 -0
  10. data/docs/agents/mcp-contract.md +109 -4
  11. data/docs/agents/owner-closeout-93.md +185 -0
  12. data/docs/agents/practice-editions-98.md +816 -0
  13. data/docs/agents/practice-pdf-installable-98.md +250 -0
  14. data/docs/agents/practice-runtime-archives-98.md +147 -0
  15. data/docs/agents/private-practice-verifier-98.md +296 -0
  16. data/docs/agents/source-ci-98.md +148 -0
  17. data/scripts/check_practice_verifier_runtime.py +69 -0
  18. data/scripts/export_practice_runtime_archives.py +138 -0
  19. data/scripts/manual/filters/practice-finish.lua +36 -0
  20. data/scripts/manual/filters/practice-layout.lua +84 -0
  21. data/scripts/manual/practice_pdf.py +31 -0
  22. data/scripts/manual/render_practice.py +125 -0
  23. data/scripts/manual/templates/practice.tex +95 -0
  24. data/scripts/prepare_practice_candidate.py +159 -0
  25. data/scripts/prepare_practice_editions_candidate.py +119 -0
  26. data/scripts/test_wheel_install.py +15 -0
  27. data/scripts/unaltraweb-mcp-bootstrap.sh +1 -1
  28. data/scripts/validate_distribution.py +18 -1
  29. data/src/unaltraweb_mcp/component-contract.json +28 -17
  30. data/src/unaltraweb_mcp/editorial_sources.py +62 -4
  31. metadata +19 -2
@@ -0,0 +1,296 @@
1
+ # Web #104 — native private QGIS delegation and independent-review corrections
2
+
3
+ PR #104 source adaptation authorized after QGIS 0.6.0 release. No private QGIS wheel,
4
+ parser or implementation is bundled into the public Web wheel/image. Acquisition
5
+ and authenticated admission of the private controller/engine belong to the hub.
6
+ The Web bridge only transports bounded structured requests to the existing native
7
+ producer CLI. Independent review of the corrected HEAD and publication remain
8
+ separate gates.
9
+
10
+ ## Exact, distinct identities
11
+
12
+ The package-owned `practice-verifier-runtime.json` is exposed read-only through
13
+ `web://practice-verifier-runtime`. It distinguishes:
14
+
15
+ - Prepared private controller:
16
+ `sha256:1fe4a54a42cbac4a6eea596be73425985ab3bca5f28f5871d2fa66d7c0a05f8b`.
17
+ - Native `practice-schema` installed-package identity: `unaltracaptura-qgis 0.6.0`,
18
+ revision `sha256:4dcd2c40bca4836a202c0c5cc400553b0f134925023b7300e0c0ff2fff1d8492`.
19
+ - V1 schema `9bc7f15ede9d67b3e1209c329da13d9fcb08004090d05b27662a43de6edf3445`;
20
+ v2 schema `528c8be229c72c346f134ffaa60094f94224094a25b6fb11bcc87802ddc7fffb`.
21
+ - Engine `sha256:1003721d96abe9607d8337b36851f9b29f84c2f8f92b377d1a73ff03c9d15cd2`.
22
+ - Owner release source/tag: `ba68ebaf57d6066e1922bc79e76aec451c24084e`, `v0.6.0`.
23
+ - Release practice-producer revision:
24
+ `sha256:2e4d6e1eb5a364162770921fc416f9cf64c60540868c0d4c907b993a779d0cc9`.
25
+ - Release wheel: 331788 bytes,
26
+ `dde653f7a719d8dbb20b4471e3cdb7f9c99cbdfcb116647caba5a08e710c7b76`.
27
+
28
+ The OCI source label is actually absent (`null`). It is not relabelled as the
29
+ release Git commit. The release wheel and practice-producer identities are owner
30
+ release declarations, not fabricated pip installation evidence. Native schemas
31
+ report the verifier's installed-package revision `4dcd…`, independently confirmed
32
+ by the hub; using producer revision `2e4…` as that verifier pin is refused.
33
+
34
+ ## External release receipt for H2
35
+
36
+ `release.receipt` declares the producer's external private receipt:
37
+
38
+ ```text
39
+ https://github.com/dosquartsdedocs/unaltracaptura-qgis/releases/download/v0.6.0/release.json
40
+ Bytes: 1251
41
+ SHA-256: 45db1e016e666126e5a51f157be0be1066aaa08f32042f5907d46e0e7da1e404
42
+ ```
43
+
44
+ The authenticated asset bytes were checked against this hash. The receipt agrees
45
+ on source, wheel, practice-producer revision and both image IDs above. Its
46
+ `mcp_image.archive` identifies the private, two-image Docker-save asset
47
+ `unaltracaptura-qgis-0.6.0-docker.tar.gz`: 1372004858 bytes, SHA-256
48
+ `d8570bdf350d7fa59fef787aca3c4b1be841ac14e5246586748aefef51a1d31f`.
49
+
50
+ The hub acquires and validates that receipt/archive through its authenticated
51
+ preparation path before H2 loading and activation. Web ships only the external
52
+ declaration; public CI does not fetch it. The receipt is QGIS-owned provenance,
53
+ not a Web attestation or a claim that Web has republished those private bytes.
54
+ The declared archive source also does not replace the actually absent OCI label.
55
+
56
+ ## Selection and native boundary
57
+
58
+ The existing launcher accepts both startup-fixed entries together, as one JSON
59
+ argument to `serve --worker-images` after the hub has prepared the images:
60
+
61
+ ```json
62
+ {
63
+ "manual_pdf": {
64
+ "reference": "sha256:618163d3f924439792b2306617bd9612f566c5881166102fa544b4e782d569fa",
65
+ "expected_image_id": "sha256:618163d3f924439792b2306617bd9612f566c5881166102fa544b4e782d569fa"
66
+ },
67
+ "practice_verifier": {
68
+ "reference": "sha256:1fe4a54a42cbac4a6eea596be73425985ab3bca5f28f5871d2fa66d7c0a05f8b",
69
+ "expected_image_id": "sha256:1fe4a54a42cbac4a6eea596be73425985ab3bca5f28f5871d2fa66d7c0a05f8b"
70
+ }
71
+ }
72
+ ```
73
+
74
+ Configuration-ID references remain usable after Docker-save loading even when
75
+ registry `RepoDigests` are absent. The published PDF digest and producer remain
76
+ unchanged. A direct CLI uses the same package-owned accepted QGIS image; optional
77
+ `PRACTICE_VERIFIER_IMAGE` must still resolve to that exact expected ID. For a direct
78
+ CLI using the loaded PDF by ID, also set `MANUAL_PDF_IMAGE` explicitly. Edition
79
+ content cannot choose an arbitrary executable or image implementation.
80
+
81
+ The live `runtime_identity` tool/resource exposes these observations under
82
+ `runtime.workers.manual_pdf` and `runtime.workers.practice_verifier`: `reference`,
83
+ `expected_image_id`, `observed_image_id`, and `state` (`matched`, `missing`,
84
+ `mismatch`, `unknown`), plus available `registry_digests`, `source_revision`, `os`
85
+ and `architecture`. Observation inspects local images without pulling or invoking
86
+ private code. A matched image is not a native schema, bundle or reopen acceptance.
87
+
88
+ The QGIS engine is the descriptor's separate `engine.image_id`, not an additional
89
+ Web worker selectable by `--worker-images`. The bridge checks that exact engine
90
+ before explicit `reopen`; QGIS owns its execution and native observations.
91
+ `web://practice-verifier-runtime` supplies declared release/package/profile pins,
92
+ while schema calls and operation receipts retain the separate native observations.
93
+
94
+ The bridge uses `python3 -I -B -m veure_qgis_mcp.cli` **inside** the private image:
95
+ `practice-schema --profile ...`, `practice-check`, and explicit `practice-reopen`.
96
+ Requests are built from bounded typed fields, not consumer command strings.
97
+ There is no private Python import in Web and no local-wheel fallback.
98
+
99
+ All invocations select an already prepared image with `--pull never`, network
100
+ `none`, readonly container root, 2 CPU / 4 GiB / 512 PIDs and bounded output/time.
101
+ Schema inspection has no consumer mount; checks have one readonly consumer mount
102
+ and no Docker socket. Explicit reopen uses its own receiver workspace and a Docker
103
+ socket to let the producer launch the already prepared engine. Reopen algorithms
104
+ and engine resource handling remain QGIS-owned. Existing mount encoding, bounded
105
+ subprocesses, startup consumer binding and token/session-scoped worker cleanup
106
+ are reused; `practice-verifier` is an owned worker role, not a new manager.
107
+
108
+ A missing private image returns an explicit authenticated-hub-preparation blocker.
109
+ No pull, build, token lookup or credential forwarding is attempted. Cleanup must
110
+ confirm exact-container absence before any successful operation is returned.
111
+
112
+ ## New edition origin and legacy preservation
113
+
114
+ For a new v2 edition, the verifier portion is:
115
+
116
+ ```yaml
117
+ gis:
118
+ profile: qgis-practice-v2
119
+ verifier:
120
+ origin: prepared-image
121
+ package: unaltracaptura-qgis
122
+ version: 0.6.0
123
+ revision: sha256:4dcd2c40bca4836a202c0c5cc400553b0f134925023b7300e0c0ff2fff1d8492
124
+ image_id: sha256:1fe4a54a42cbac4a6eea596be73425985ab3bca5f28f5871d2fa66d7c0a05f8b
125
+ schema_sha256: 528c8be229c72c346f134ffaa60094f94224094a25b6fb11bcc87802ddc7fffb
126
+ ```
127
+
128
+ V1 uses its own profile/schema hash above. The independent `gis.bundle` and sender
129
+ seal still refer to the original producer, including retained rc2/rc1 bundles.
130
+ Legacy installed-wheel configuration shapes remain schema-valid but execution
131
+ through this new public runtime explicitly refuses them. Keep their configuration,
132
+ receipts and original candidate intact; create a new configuration/edition for the
133
+ new origin instead of rewriting history or inventing a `direct_url`.
134
+
135
+ Web validates the complete common bundle before any product selection. Native
136
+ selection must agree with that inventory, include its complete dependency closure,
137
+ and satisfy recipient policy. Receipts retain the original producer report and a
138
+ separate image/native-execution origin, including bounded execution and teardown.
139
+
140
+ ## Public CI and local acceptance
141
+
142
+ `Dockerfile.mcp` no longer installs QGIS. The private dependency is declared in
143
+ the BOM as external release metadata; the runtime descriptor supplies image pins.
144
+ `scripts/check_practice_verifier_runtime.py` checks metadata by default and reports
145
+ private checks as `not_run`, even on a machine with private images already present.
146
+ `--check-runtime --project ABSOLUTE_ROOT` explicitly opts into native schema/identity
147
+ checks. Full bundle/reopen acceptance and independent review remain distinct gates.
148
+
149
+ The owner built a new **local, unpublished public-Web probe image** from the
150
+ already public 0.7.1 base, with only Web source/wheel changes and network disabled.
151
+ Absence of the private distribution and `veure_qgis_mcp` module was asserted inside
152
+ the public image. It then delegated to the prepared private image above.
153
+
154
+ Local Web probe configuration ID:
155
+ `sha256:555bea3744354cde3f64e5ac7e4a4fd01ff71d8cfcf923336bdb577c89ed809a`;
156
+ public source snapshot:
157
+ `0de3ebb5bffb0b93291b7144daa3e6b0fbd3f45beed64ed9580f13626fff91f8`.
158
+ Its manifest is retained under `tmp/private-verifier-98/public-probe-02/`.
159
+ This is an owner acceptance build, not a published or independently reviewed image.
160
+
161
+ Earlier candidate-04 `17ef…` contains the old embedded private wheel and remains
162
+ a private/local historical artifact. Its saved archive must not be attached to
163
+ the public Web release. New public release archives must use the Web-only source
164
+ boundary and provider-package-absence check exercised here.
165
+
166
+ Local evidence:
167
+
168
+ ```text
169
+ /home/benizar/git/unaltraweb/tmp/private-verifier-98/acceptance-01/evidence.json
170
+ SHA-256 99878f6caa002dd3ab22f6559d462418465cc4b12fe36195b39c401065302b97
171
+ /home/benizar/git/unaltraweb/tmp/private-verifier-98/acceptance-01/supplement.json
172
+ SHA-256 9351c8e7943d7dafdb77f482acdef0e102b5f211c2dca68678bac9e494e7d27a
173
+ ```
174
+
175
+ The native v2 composition produces the two PDF/ZIP pairs. Extracted ZIP bytes
176
+ replace every selected resource in a new receiver before reopen. Observed engine
177
+ mounts contain only the exact selected readonly packet plus provider controls and
178
+ output, not the full private bundle/original consumer. Missing/altered final ZIP
179
+ dependencies remain refused even after the ZIP hash is recalculated. Native
180
+ observations confirm embedded `inici`, 600×640 MDT/ortho, themes and distinct
181
+ 15,000/75,000 layout frames. Retained v1 data also verifies through the same image.
182
+ Wrong image/profile, a producer revision used as verifier identity, legacy origin
183
+ and an unavailable runtime are refused. Original fixtures/rc2 configurations remain
184
+ unchanged. This is local owner acceptance, not a public-CI or independent-review pass.
185
+
186
+ ## Independent review P2 corrections
187
+
188
+ The review of PR #104 at `88dd2518` reported three issues; all reproduced before
189
+ the fixes (`tmp/private-verifier-98/review-p2-before.log`):
190
+
191
+ 1. PDF delivery now uses hashes returned by the build's original PDF/receipt seal.
192
+ Composition checks and snapshots that seal immediately, before building the
193
+ other recipient. Late PDF edits or replacement/resealing of its receipt cannot
194
+ become authoritative new output hashes. Atomic no-replace publication remains.
195
+ 2. Existing TinyCSS2 tokenization now covers URL-bearing SVG presentation values,
196
+ including escaped `u\72l(...)`; escaped imports are rejected by token identity.
197
+ Declared local dependencies remain usable. No private producer parser was copied.
198
+ 3. The shared privacy check includes actual/mapped `_practices` roots, requires
199
+ exclusion and rejects explicit/glob includes, also in standalone PDF preparation.
200
+
201
+ Regression negatives rehash the edited SVG and replace the PDF receipt to exercise
202
+ real resealing attempts. Both output copies must match the original reading seal.
203
+ The independent [re-review of `e16e2478`](https://github.com/dosquartsdedocs/unaltraweb/pull/104#pullrequestreview-5485508017)
204
+ explicitly resolved these three findings. The external receipt reference was added
205
+ after the retained local probe without changing its executable bridge/compositor
206
+ bytes or accepted image/schema selections.
207
+
208
+ ## Create-stage teardown correction after e16e2478
209
+
210
+ The same re-review identified [P2 4243700163](https://github.com/dosquartsdedocs/unaltraweb/pull/104#discussion_r4243700163):
211
+ `docker create` could allocate a container and then time out or return a truncated
212
+ or malformed reply before entering the cleanup guard. A never-started container
213
+ is not retired by `--rm`.
214
+
215
+ Creation, reply validation and start/attach now share the guarded lifecycle.
216
+ The already minted project/token labels scope the existing native worker cleanup,
217
+ including direct CLI calls without a session and creation with uncertain effects.
218
+ Recovery uses the bounded exact-token daemon inventory; an untrusted reply or
219
+ container name is never removal authority. Before `start`, the daemon-observed
220
+ full CID, image, factory, role, project, token and session must match the invocation.
221
+ The shared cleanup helper accepts the bridge's expected image/session, revalidates
222
+ every listed CID and removes only positively verified objects. Objects already
223
+ observed absent are not passed to `rm`.
224
+
225
+ Inventory/removal timeouts, either truncated stream, Docker errors, ownership
226
+ mismatches and unknown absence produce `teardown is unconfirmed`, with the worker
227
+ token for diagnosis and the original operation failure retained in the exception
228
+ chain. Cleanup must confirm known-CID absence and an empty scoped inventory.
229
+ Observation is bounded; private execution, request arguments, mounts, limits and
230
+ the selected image/profile pins retain their previous semantics.
231
+
232
+ Regression evidence is create-only under `/tmp/opencode/web104-create-cleanup-01/`:
233
+
234
+ - `red.log`: seven create-stage tests, 30 failing cases on the original executable
235
+ bytes; SHA-256 `de80dcba863548ceb2b530bb39a90c25bf3c7b1ef953f10e0fb52df9272b6699`.
236
+ - `green.log`: all 18 bridge tests pass, including malformed/truncated/lost create
237
+ replies, allocation failure before create, cleanup failures, foreign CIDs,
238
+ ownership/image/session mismatches, Docker/absence errors and the positive
239
+ bounded reopen contract; SHA-256
240
+ `70925737a0e85516e4d349ceef54527a963bb48f9e0a350b0c7e573a39c14dcb`.
241
+ - `lifecycle.log`: all 35 shared site/lifecycle tests pass; SHA-256
242
+ `74d62aeec13ef065365713a469238f4fd09c35956888dfa6066a0b504d638dec`.
243
+ - `native.json`: actual prepared-image allocations followed by injected timeout,
244
+ truncated and malformed create replies, plus one successful native schema call.
245
+ All four exact CIDs are absent. No model, PDF or render was run. SHA-256
246
+ `8ae0fe6ead4b1cd93c721041fce70508c289fc61bcee2b3309aba734ed5e34ef`.
247
+ - `full.log`: 729 tests, 701 passed / 28 optional skips, including an additional
248
+ stale-inventory regression that forbids removing a CID already observed absent.
249
+ SHA-256 `0d6bc4249c40346a4f01c3f4a8d6e005a0740e439c7dc050d9fa35c550f60f2d`.
250
+
251
+ The hub's earlier independent Web-only identity/capabilities/binding/EOF probe
252
+ remains retained at `/tmp/opencode/practice33-web-native-private-01/evidence.json`,
253
+ SHA-256 `ced4a6262d87674b4ff2fa5cff7290e92d1cd33f834edfa1f694ced7d500c6cf`.
254
+ That probe describes its original `555bea37…` image, not the new cleanup bytes or
255
+ release acceptance. This lifecycle delta needs its own TIG review on the new HEAD;
256
+ the earlier PDF/ZIP renders, descriptor `f17eec24…` and private receipt `45db1e01…`
257
+ remain unchanged.
258
+
259
+ ## Real MCP resource-read correction after the 0.7.2 candidate
260
+
261
+ PR #104 was integrated at `da4e1fcc875211c4eff75834b45d2c4887adec0b` with the
262
+ reviewed tree. Its downloaded wheel `e16a7586…` and public image configuration
263
+ `9bd8b6a8…` passed PDF/ZIP composition and exact-packet reopen, but native
264
+ `resources/read` for `web://practice-verifier-runtime` failed with
265
+ `NameError: name 'json' is not defined`. The callback was the module's only direct
266
+ use of the `json` global; the adjacent practice-edition resource reads its packaged
267
+ schema directly and the other JSON resources use the existing `tools.dumps` helper.
268
+
269
+ The production correction adds only `import json` to `mcp_server.py`. The regression
270
+ starts an actual CLI server subprocess over MCP stdio, then performs `initialize`,
271
+ `resources/list` and `resources/read` for both practice resources. It parses the
272
+ responses, compares them with the packaged JSON and checks the accepted image,
273
+ native-package revision, producer revision, schema and external-receipt pins.
274
+ The child has no external executables on `PATH`; the test needs neither a Docker
275
+ daemon nor private QGIS data, images, downloads, renders or a site build. No callback
276
+ or serializer is patched into the server. The same assertion is called by the
277
+ installed-image MCP smoke, so listing a broken resource can no longer pass that gate.
278
+
279
+ Create-only source regression evidence:
280
+
281
+ - `/tmp/opencode/web98-resource-fix-01/red.log`: the unchanged `da4e1fcc` server
282
+ fails the real resource read with the recorded NameError; SHA-256
283
+ `c09ddc7e595b789580c44dad668a734d8b92c70b5c7f69078f01bab108d099cf`.
284
+ - `/tmp/opencode/web98-resource-fix-01/green.log`: the same test passes after the
285
+ one-line import correction; SHA-256
286
+ `752562e45d6b624f56dd707f2f8756ebb8051c9d049974986cc4c44450a993ed`.
287
+ - `/tmp/opencode/web98-resource-fix-01/full.log`: 730 tests, 702 passed / 28 optional
288
+ skips; the real MCP resource test runs and passes. SHA-256
289
+ `c5b77cd7abd1ae3d5cc20b50d2b88d2528e6e7ec7bc7f37d89485b3317fb1e33`.
290
+
291
+ The blocked-candidate checkpoint
292
+ `/tmp/opencode/unaltraweb-072-104/blocked-checkpoint.json` retains SHA-256
293
+ `c51c6fbf2f0661804b9e241552cc16939f2efcbf61bdf6f9e4021c06be2b2189`.
294
+ Those immutable candidates remain blocked; this source fix does not repair their
295
+ bytes or authorize their release. Independent delta review, integration and new
296
+ same-source candidates remain required before resuming 0.7.2 publication.
@@ -0,0 +1,148 @@
1
+ # Shared public-source CI — Web #98
2
+
3
+ One implementation lives in `unaltraweb_mcp.source_checks`. The package-owned
4
+ control `.github/workflows/source-checks.yml` is identical for the three manuals;
5
+ only `.unaltraweb/source-checks.json` contains project-specific data. It is a
6
+ source-conservation lane, not a site build or retained-input validation lane.
7
+
8
+ ## GitHub visibility and installation boundary
9
+
10
+ [GitHub's reusable-workflow access table](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#access-to-reusable-workflows)
11
+ allows public callers to use public workflows, not private ones. Cross-owner
12
+ private access must therefore not be assumed. The repository API reported Web
13
+ as `PUBLIC` during this inspection; the design does not depend on that visibility
14
+ remaining public and no visibility setting was changed.
15
+
16
+ The local scaffold control uses public, SHA-pinned GitHub actions and the public
17
+ PyPI package. It does not call a private factory workflow or download code from a
18
+ mutable factory checkout. Public PyPI 0.7.1 and anonymous access to the selected
19
+ checker-image manifest were verified. The new runner is intended for **0.7.2**:
20
+ the prepared 0.7.2 control must be activated after that package is published.
21
+ Public 0.7.1 does not contain this entrypoint. A missing runner/install/report is
22
+ a failed/incomplete job, never a successful omission.
23
+
24
+ The runner is installed into a fresh runner-temporary venv with isolated Python
25
+ and pip, `--only-binary=:all:`, and `--no-deps`. It uses the standard library and
26
+ existing native subprocess/lifecycle helpers. Project `requirements.txt`, Python,
27
+ R, shell, models and configuration programs are never executed to discover policy.
28
+
29
+ The controller used for the existing checkers is independently pinned:
30
+
31
+ ```text
32
+ ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:6aa20cff86c3891a876e37ba0548fa36f3c5633c9f22ba672e9f50123d677cf6
33
+ ```
34
+
35
+ Acquisition of this public image is explicit in the workflow. During checking,
36
+ containers use `--pull never`, network `none`, a readonly root and clone mount,
37
+ no Docker socket, 2 CPU / 4 GiB / 512 PIDs and a bounded tmpfs. The existing exact
38
+ session lifecycle handles their native labels/CIDs; the runner does not implement
39
+ another resource manager.
40
+
41
+ ## Common control and site-owned parameters
42
+
43
+ The source template is:
44
+
45
+ ```text
46
+ src/unaltraweb_mcp/scaffolds/common/.github/workflows/source-checks.yml.tmpl
47
+ ```
48
+
49
+ It is rendered once per package version and checker-image selection, without
50
+ project title/language/profile substitutions. `new_web` includes it in the managed
51
+ scaffold baseline. A changed local workflow remains protected by the existing
52
+ no-clobber/conflict/CAS synchronization rules. The JSON policy is seeded for new
53
+ sites but remains site-owned and is never overwritten by `scaffold_sync`.
54
+
55
+ Example manual policy:
56
+
57
+ ```json
58
+ {
59
+ "schema_version": 1,
60
+ "profile": "unaltremanual",
61
+ "public_roots": ["_chapters", "_pages"],
62
+ "practice_roots": ["practiques", "_practices"],
63
+ "data_roots": ["sandbox", "dist", "assets/pdf"],
64
+ "allow_empty_public_sources": false
65
+ }
66
+ ```
67
+
68
+ The exact schema is packaged as `source-check-policy-v1.schema.json`. Policy is
69
+ bounded strict JSON in a regular, committed Git blob. Unknown keys (including
70
+ commands), duplicate keys/roots, traversal and Git-internal roots are refused.
71
+ Other site profiles use the same control with their declared public roots.
72
+
73
+ Add every activity's data/output directory to `data_roots`, including directories
74
+ holding JSON datasets. Within practice roots, source formats such as Markdown,
75
+ JSON/YAML recipes, Python/R, notebooks, `.model3`, QML/SLD and authored SVG remain
76
+ eligible. Other formats, including PDF/ZIP, raster/vector datasets and captures,
77
+ are refused when new or modified. JSON or SVG cannot be semantically classified
78
+ as code versus data by filename alone: declared data roots take precedence and
79
+ block all new/changed files there. Reproduction sources belong outside those roots.
80
+
81
+ Legacy means the **same path, mode, type and Git object** as the baseline. Such
82
+ tracked data is listed as `legacy_unchanged`, not deleted or silently migrated.
83
+ New copies/renames or modified binary bytes are not grandfathered. Deletions are
84
+ allowed. Base and head data/practice roots are combined for the PR; the same PR
85
+ cannot evade its baseline restrictions by narrowing policy. Public scope and the
86
+ empty-coverage guard similarly retain the baseline requirement until a reviewed
87
+ policy change becomes the next baseline.
88
+
89
+ ## Exact scope and reports
90
+
91
+ The runner invokes the existing `profile-check` and `prose-check` CLIs. A separate
92
+ `content-approval-inventory` call is an observation for the draft/status summary,
93
+ not an approval check or promotion. The last call's bounded sample is explicitly
94
+ marked partial when counts exceed the sample. Private handouts outside those
95
+ native collections are not claimed as reviewed.
96
+
97
+ The reports directory must be new and outside the checkout. It retains each raw
98
+ structured checker result plus `summary.json` and `summary.md` containing:
99
+
100
+ - explicit covered paths and declared public scope;
101
+ - native exclusions, including unpublished/different-profile sources;
102
+ - missing/unreported coverage, unsupported languages and figures `unverified`;
103
+ - observed draft/status counts and bounded samples without changing statuses;
104
+ - refused additions/changes and unchanged legacy files;
105
+ - `not_run` for models, datasets, captures, PDF and ZIP validation.
106
+
107
+ The guard requires real text sources in the declared roots; `_config.yml` alone
108
+ does not count. An intentionally empty scope requires
109
+ `allow_empty_public_sources: true` and a nonempty `empty_reason`, and is reported
110
+ as `empty_declared`, never as checked prose. Unexpected omissions fail. Checked
111
+ text and checker metadata must match their Git blobs; comparison reads bytes
112
+ directly and does not run Git clean/textconv filters or project policy code.
113
+
114
+ Source checks preserve warnings and partial coverage rather than interpreting a
115
+ checker exit zero as full validation. Nonzero exit, `ok:false`, malformed or
116
+ truncated output, timeout and unconfirmed exact teardown fail the source job.
117
+ No `continue-on-error` masks these outcomes.
118
+
119
+ The workflow listens to `pull_request` and optional manual dispatch, with only
120
+ `contents: read`, no deployment secrets and no Pages job. PR baseline identity
121
+ comes from the event's base commit. Manual dispatch requires an explicit full
122
+ ancestor SHA. It uses the checkout's committed HEAD, not uncommitted work as a
123
+ replacement baseline. The native `site-deploy.yml` and `reviewed_sha` remain
124
+ separate; source-CI success does not authorize website/Moodle publication.
125
+
126
+ ## Minimal invocation and adoption
127
+
128
+ After installing the containing public package and explicitly preparing the
129
+ checker image:
130
+
131
+ ```bash
132
+ python -I -m unaltraweb_mcp.source_checks \
133
+ --project /absolute/clean-clone \
134
+ --base-sha FULL_ANCESTOR_COMMIT_SHA \
135
+ --reports /absolute/reports-outside-clone
136
+ ```
137
+
138
+ `unaltraweb-source-checks` exposes the same arguments. The runner itself never
139
+ pulls or builds an image. It does not require private `sandbox`, dataset, PDF or
140
+ ZIP deliveries to exist. Full scientific/output validation remains a separate
141
+ retained-input lane, with its own source/pin/hash-bound evidence.
142
+
143
+ For the existing manuals, owners can add the exact common workflow and their
144
+ JSON policy in their authorized control change. They need not copy this Python
145
+ implementation into each repository. Do not forge a new scaffold baseline or
146
+ resolve an unrelated deployment-workflow conflict to add source CI: exact package
147
+ control bytes can be adopted later by the normal baseline mechanism. Existing
148
+ Geodisseny deployment/`reviewed_sha` customization remains untouched.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """Validate the private-runtime declaration; native checks require explicit opt-in.
3
+
4
+ There is deliberately no installation/download operation. Public build/CI checks
5
+ metadata and unit fixtures only; the hub prepares the authenticated private images.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ from pathlib import Path
12
+ import sys
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(ROOT / "src"))
16
+ from unaltraweb_mcp import practice_verifier
17
+ from unaltraweb_mcp.distribution import distribution_contract
18
+
19
+ COMPONENT = "qgis_practice_verifier"
20
+
21
+
22
+ def required(contract):
23
+ return "manual-practice-compose" in contract.get("wheel_contract", {}).get("factory_required_mcp", [])
24
+
25
+
26
+ def publication_errors(contract):
27
+ if not required(contract):
28
+ return []
29
+ try:
30
+ runtime = practice_verifier.runtime_contract()
31
+ item = contract["components"][COMPONENT]
32
+ if (item["kind"] != "companion" or item["name"] != runtime["provider"]["name"]
33
+ or item["version"] != runtime["provider"]["version"] or item["release_status"] != "released"
34
+ or item["release"] != runtime["release"]["tag"] or item["included_in_wheel"] is not False
35
+ or item["repository"] != runtime["release"]["repository"]
36
+ or item["reference"] != item["repository"] + ".git@" + item["release"]
37
+ or not set(runtime["profiles"]) <= set(item["features"])):
38
+ return ["Private QGIS runtime metadata differs from the accepted release declaration"]
39
+ except (KeyError, TypeError, OSError, ValueError) as exc:
40
+ return ["Private QGIS runtime declaration is incomplete: " + str(exc)]
41
+ return []
42
+
43
+
44
+ def main(argv=None):
45
+ parser = argparse.ArgumentParser(description=__doc__)
46
+ parser.add_argument("--check-runtime", action="store_true", help="Opt in to native checks on already hub-prepared private images")
47
+ parser.add_argument("--project", type=Path, help="Explicit bound workspace for the private check")
48
+ args = parser.parse_args(argv)
49
+ errors = publication_errors(distribution_contract())
50
+ result = {"ok": not errors, "errors": errors, "origin": "prepared-image",
51
+ "private_checks": {"state": "not_run", "reason": "Public CI does not acquire or execute the private QGIS runtime"}}
52
+ if args.check_runtime:
53
+ try:
54
+ if errors or args.project is None or not args.project.is_absolute():
55
+ raise ValueError("Native checking requires valid metadata and an explicit absolute project")
56
+ profiles = {}
57
+ for profile in practice_verifier.runtime_contract()["profiles"]:
58
+ observed = practice_verifier.call(args.project, "schema", profile=profile)
59
+ value = observed["result"]
60
+ profiles[profile] = {"package": value["installed_package"], "schema_sha256": value["schema_sha256"], "origin": observed["origin"]}
61
+ result["private_checks"] = {"state": "passed", "scope": "image/package/schema identity only; bundle/reopen acceptance is separate", "profiles": profiles}
62
+ except (OSError, ValueError, RuntimeError) as exc:
63
+ result.update(ok=False, errors=[*errors, str(exc)], private_checks={"state": "blocked", "reason": str(exc)})
64
+ print(json.dumps(result, indent=2, sort_keys=True))
65
+ return 0 if result["ok"] else 2
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main())
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Export two prepared images for the hub's existing Docker-save loader.
3
+
4
+ No build, pull, load, retag, installation or resource manager is implemented here.
5
+ The existing bounded exporter is reused; PDF provenance comes from its own BOM
6
+ record rather than the controller's source commit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import shutil
17
+ import sys
18
+ import tarfile
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(ROOT))
22
+ sys.path.insert(0, str(ROOT / "src"))
23
+ from scripts.prepare_practice_candidate import export_image, image, sha256
24
+ from unaltraweb_mcp.distribution import distribution_contract
25
+ from unaltraweb_mcp.runtime_lifecycle import IMAGE_ID, immutable_reference
26
+
27
+
28
+ def verify_archive(path, expected_id, expected_source):
29
+ """Inspect only Docker-save metadata/config; never extract or load layers."""
30
+ with tarfile.open(path, "r:gz") as archive:
31
+ records = archive.getmembers()
32
+ by_name = {entry.name: entry for entry in records}
33
+ if len(records) != len(by_name) or len(records) > 10000:
34
+ raise ValueError("Docker-save archive inventory is ambiguous or excessive")
35
+ def small_json(name):
36
+ entry = by_name.get(name)
37
+ if entry is None or not entry.isfile() or entry.size > 2*1024*1024:
38
+ raise ValueError("Docker-save metadata is missing or invalid")
39
+ with archive.extractfile(entry) as stream:
40
+ raw = stream.read()
41
+ return json.loads(raw), raw
42
+ manifest, _ = small_json("manifest.json")
43
+ if not isinstance(manifest, list) or len(manifest) != 1:
44
+ raise ValueError("Expected one immutable image per Docker-save archive")
45
+ item = manifest[0]
46
+ if item.get("RepoTags"):
47
+ raise ValueError("Export by configuration ID only; archive must not reassign tag aliases")
48
+ config, raw = small_json(item.get("Config"))
49
+ if "sha256:" + hashlib.sha256(raw).hexdigest() != expected_id:
50
+ raise ValueError("Docker-save config bytes differ from the expected image ID")
51
+ if config.get("config", {}).get("Labels", {}).get("org.opencontainers.image.revision") != expected_source:
52
+ raise ValueError("Docker-save image producer differs from its own expected source")
53
+ if (config.get("os"), config.get("architecture")) != ("linux", "amd64"):
54
+ raise ValueError("Archive must retain the selected Linux/amd64 platform")
55
+ for name in item.get("Layers", []):
56
+ if name not in by_name or not by_name[name].isfile():
57
+ raise ValueError("Docker-save layer is missing")
58
+ return {"config_id": expected_id, "source_revision": expected_source, "repo_tags": []}
59
+
60
+
61
+ def write_new(path, value):
62
+ with path.open("x") as stream:
63
+ stream.write(json.dumps(value, indent=2, sort_keys=True) + "\n")
64
+ stream.flush()
65
+ os.fsync(stream.fileno())
66
+
67
+
68
+ def prepare(output, controller_reference, controller_id, controller_source, pdf_id, *, controller_archive=None, controller_archive_sha256=None):
69
+ output = Path(output).absolute()
70
+ if (output.resolve() != output or not any(output.is_relative_to(root) for root in (ROOT / "tmp", Path("/tmp/opencode")))
71
+ or not output.parent.is_dir()):
72
+ raise ValueError("Select a new non-symlink export directory under factory tmp/ or /tmp/opencode/")
73
+ if os.path.lexists(output):
74
+ raise FileExistsError("Runtime archive delivery already exists")
75
+ if not immutable_reference(controller_reference) or not IMAGE_ID.fullmatch(controller_id) or not IMAGE_ID.fullmatch(pdf_id):
76
+ raise ValueError("Select immutable image references and full expected configuration IDs")
77
+ if not re.fullmatch(r"(?:[0-9a-f]{40}|sha256:[0-9a-f]{64})", controller_source):
78
+ raise ValueError("Controller source must be its exact Git or sealed snapshot identity")
79
+ if bool(controller_archive) != bool(controller_archive_sha256):
80
+ raise ValueError("Reusing a controller archive requires its exact SHA-256")
81
+ contract = distribution_contract()
82
+ pdf_reference = contract["consumer_integration"]["manual_pdf_image"]
83
+ producers = [row["producer_sha"] for row in contract["deployment_contract"]["manual_pdf_workers"] if row["reference"] == pdf_reference]
84
+ if len(producers) != 1:
85
+ raise ValueError("Selected PDF needs one provider-owned producer record")
86
+ selected = {"controller": image(controller_reference, controller_id), "manual_pdf": image(pdf_reference, pdf_id)}
87
+ if selected["controller"]["source_revision"] != controller_source or selected["manual_pdf"]["source_revision"] != producers[0]:
88
+ raise ValueError("Prepared controller/PDF does not match its own producer")
89
+ if controller_archive:
90
+ controller_archive = Path(controller_archive)
91
+ if controller_archive.is_symlink() or sha256(controller_archive) != controller_archive_sha256:
92
+ raise ValueError("Retained controller archive hash differs")
93
+ verify_archive(controller_archive, controller_id, controller_source)
94
+ if shutil.disk_usage(output.parent).free < sum(item["size"] for item in selected.values()) + 256*1024*1024:
95
+ raise RuntimeError("Insufficient space for the bounded archive export")
96
+ output.mkdir(mode=0o755)
97
+ exported = {}
98
+ for role, observed in selected.items():
99
+ destination = output / ("controller.docker.tar.gz" if role == "controller" else "manual-pdf.docker.tar.gz")
100
+ if role == "controller" and controller_archive:
101
+ with controller_archive.open("rb") as source, destination.open("xb") as target:
102
+ shutil.copyfileobj(source, target, 1024*1024)
103
+ target.flush()
104
+ os.fsync(target.fileno())
105
+ if sha256(destination) != controller_archive_sha256:
106
+ raise ValueError("Copied controller archive differs")
107
+ record = {"path": destination.name, "sha256": controller_archive_sha256, "bytes": destination.stat().st_size,
108
+ "format": "docker-save-gzip", "reused_exact_bytes": True}
109
+ else:
110
+ record = export_image(observed["image_id"], destination)
111
+ archive_identity = verify_archive(destination, observed["image_id"], observed["source_revision"])
112
+ exported[role] = {"archive": record, "image": observed, "archive_identity": archive_identity,
113
+ "offline_selection": {"reference": observed["image_id"], "expected_image_id": observed["image_id"]}}
114
+ manifest = {"schema_version": 1, "kind": "unaltraweb-practice-runtime-archives", "published": False, "images": exported,
115
+ "loading": "Existing hub H2 Docker-save loader; no Web loader or alias mutation",
116
+ "digest_note": "Registry digests are pre-export observations, not a claim that docker load restores RepoDigests."}
117
+ write_new(output / "runtime-archives.json", manifest)
118
+ files = [{"path": path.name, "sha256": sha256(path), "bytes": path.stat().st_size} for path in sorted(output.iterdir())]
119
+ write_new(output / "ready.json", {"schema_version": 1, "kind": "unaltraweb-runtime-archives-ready", "ready": True,
120
+ "manifest": "runtime-archives.json", "files": files, "published": False})
121
+ for row in files:
122
+ if sha256(output / row["path"]) != row["sha256"]:
123
+ raise ValueError("Runtime archive delivery changed before handoff")
124
+ return {"ok": True, "ready": str(output / "ready.json"), "sha256": sha256(output / "ready.json"), "images": exported}
125
+
126
+
127
+ if __name__ == "__main__":
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("--output", type=Path, required=True)
130
+ parser.add_argument("--controller-image", required=True)
131
+ parser.add_argument("--controller-id", required=True)
132
+ parser.add_argument("--controller-source", required=True)
133
+ parser.add_argument("--pdf-id", required=True)
134
+ parser.add_argument("--controller-archive", type=Path)
135
+ parser.add_argument("--controller-archive-sha256")
136
+ args = parser.parse_args()
137
+ print(json.dumps(prepare(args.output, args.controller_image, args.controller_id, args.controller_source, args.pdf_id,
138
+ controller_archive=args.controller_archive, controller_archive_sha256=args.controller_archive_sha256), indent=2))