sayfirst-cli 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,553 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`sayfirst instrument verify`: run the proof harness, and read its findings.
3
+
4
+ This is the command around `harness.py`. It reads the packs the way `run` does,
5
+ so a pack that will not read is the misuse it is before anything is spawned;
6
+ writes what it was asked into a configuration the harness reads; runs the
7
+ harness in a process of its own, because the hook that stops an ungoverned
8
+ effect can never be removed from an interpreter; and turns the report into
9
+ lines and into an exit code.
10
+
11
+ **The exit code is this command's and not the program's.** `run` passes the
12
+ governed program's ending through untouched, which is right for a launcher and
13
+ wrong for a proof: here the question is whether every named effect was decided,
14
+ so the program's own ending is reported as a fact (`target exit`) and the code a
15
+ shell sees comes from the verdicts. Zero means every point of every pack was
16
+ `governed` and no event on any of them went unjudged. `not-exercised` is never
17
+ zero — a path a run did not walk is not a path proven safe, and a verifier that
18
+ reported it green would be the false all-clear article 2 forbids. Neither is an
19
+ `unjudged` count, for the same reason read one step earlier: the harness
20
+ publishes, per point, how many events it could not judge at all, and a run
21
+ holding one of those has not finished proving anything about that point,
22
+ whatever the point's verdict says about the events it did judge.
23
+
24
+ **Where the program's own output goes, and why it is not stdout.** The verified
25
+ program keeps both its streams, and both of them arrive on THIS command's error
26
+ stream. It is the diagnostic stream for a verification run: the answer to
27
+ `verify` is the report, so `--json` has to be able to write an envelope a
28
+ machine reads, and a rule that moved the program's output depending on the
29
+ rendering asked for would be worse than a rule that is the same in both.
30
+
31
+ **`--json` answers in the envelope, and on a stream the program cannot reach.**
32
+ The verified program keeps both its streams and both arrive on this command's
33
+ error stream, which is right — the answer to `verify` is the report. It also
34
+ means a program can print `{"problem": …}` there, and this repository's own
35
+ tests parse an envelope from the first `{` they find. So the envelope goes to
36
+ stdout on every path, findings or none: on a no-answer path nothing else is
37
+ written to stdout, and on a reported path the envelope is the answer. The exit
38
+ code is the same either way — the rendering is not the verdict.
39
+
40
+ **How a no-answer path is told from the program's ending.** The harness writes
41
+ a file of its own saying which of its endings happened, and this command reads
42
+ that rather than the process's exit status. The status is the TARGET's: the
43
+ program runs inside the harness's interpreter, so `os._exit(64)` in the program
44
+ ends the harness with 64 — and 64 was read here as « the invocation was refused
45
+ before it began », a sentence about a program that had in fact run. One number
46
+ and two facts; `harness._write_outcome` carries the second, and says what the
47
+ channel is honest against.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import argparse
53
+ import json
54
+ import subprocess
55
+ import sys
56
+ import tempfile
57
+ from collections.abc import Mapping, Sequence
58
+ from pathlib import Path
59
+ from typing import Final, NamedTuple, TextIO
60
+
61
+ from sayfirst_contract.generation import CONTRACT_GENERATION
62
+ from sayfirst_contract.problems import (
63
+ Problem,
64
+ ProblemCode,
65
+ classes_by_code,
66
+ problem_retryable,
67
+ )
68
+ from sayfirst_contract.transport.socket_client import (
69
+ PER_USER,
70
+ SYSTEM,
71
+ ProfileMisuse,
72
+ SocketProfile,
73
+ )
74
+
75
+ from .. import exit_codes, reads, render
76
+ from . import harness, manifest
77
+
78
+ #: The module the harness is run as. Named here so that the one place a second
79
+ #: interpreter is started names what it starts, and so a test can point it at
80
+ #: something that produces no report.
81
+ HARNESS_MODULE: Final[str] = "sayfirst_cli.instrument.harness"
82
+
83
+ #: What the configuration and the findings are called inside the directory this
84
+ #: command makes and removes. Neither outlives the run: a verification leaves
85
+ #: nothing on the machine it ran on.
86
+ CONFIGURATION_FILE: Final[str] = "verification-configuration"
87
+ REPORT_FILE: Final[str] = "verification-report"
88
+
89
+ #: Where the harness says which of ITS OWN endings happened, so this command
90
+ #: never has to read the target's exit status for it.
91
+ OUTCOME_FILE: Final[str] = "verification-outcome"
92
+
93
+ #: How long the harness may take, program included. A bound rather than none,
94
+ #: so a target that never ends is a verification that could not conclude rather
95
+ #: than a command that hangs for ever.
96
+ HARNESS_TIMEOUT: Final[float] = 900.0
97
+
98
+ #: What is said when a run concluded nothing, one sentence per reason. Each is
99
+ #: the prose a person reads and the `message` of the problem a machine reads,
100
+ #: so the two renderings cannot drift into saying different things.
101
+ NO_OUTCOME: Final[str] = (
102
+ "no findings: the verification did not run to a conclusion, so nothing about this "
103
+ "program is claimed either way (article 2)"
104
+ )
105
+ NOT_BEGUN: Final[str] = (
106
+ "no findings: the verification was refused before it began, and the harness said "
107
+ "why on this stream"
108
+ )
109
+ CHAIN_UNREADABLE_BEFORE_MESSAGE: Final[str] = (
110
+ "no findings: the chain could not be read before the program started, so nothing was "
111
+ "ever watched and nothing about this program is claimed"
112
+ )
113
+ CHAIN_UNREADABLE_DURING_MESSAGE: Final[str] = (
114
+ "no findings: the chain could not be read while the program ran, so the effect was "
115
+ "aborted on an unknown and nothing about this program is claimed"
116
+ )
117
+ UNREADABLE_FINDINGS: Final[str] = "the findings are not a report this client reads"
118
+
119
+ #: The sentence each of the harness's own endings is said with. Five endings and
120
+ #: five sentences, because « the chain could not be read » and « the gate never
121
+ #: opened » are different facts about different things and were one
122
+ #: `answer_unreadable` carrying one sentence between them — the cause surviving
123
+ #: only as prose the harness wrote on the error stream, which a machine reader
124
+ #: of the envelope never saw.
125
+ _SAID: Final[dict[str, str]] = {
126
+ harness.INVOCATION_REFUSED: NOT_BEGUN,
127
+ harness.CHAIN_UNREADABLE_BEFORE: CHAIN_UNREADABLE_BEFORE_MESSAGE,
128
+ harness.CHAIN_UNREADABLE_DURING: CHAIN_UNREADABLE_DURING_MESSAGE,
129
+ harness.GATE_NEVER_OPENED: harness.NEVER_STARTED,
130
+ harness.REPORTED: UNREADABLE_FINDINGS,
131
+ }
132
+
133
+ #: The code an ending answers with when THIS CLIENT is the one that knows what
134
+ #: happened: an invocation it refused, a gate it never saw open, findings it
135
+ #: cannot read. Three rows and not five — the two chain endings are a read the
136
+ #: contract classified, and their code travels in the outcome file rather than
137
+ #: being minted here. A client that re-minted one would be publishing its own
138
+ #: classification of a problem the control plane already named, which is
139
+ #: article 1's rule about deriving an answer nobody gave, applied to a problem;
140
+ #: the measured cost of doing it was `unreachable` for a daemon that was
141
+ #: reached, answered, and spoke a generation this client does not read.
142
+ _NO_ANSWER: Final[dict[str, ProblemCode]] = {
143
+ harness.INVOCATION_REFUSED: ProblemCode.REQUEST_MALFORMED,
144
+ harness.GATE_NEVER_OPENED: ProblemCode.ANSWER_UNREADABLE,
145
+ harness.REPORTED: ProblemCode.ANSWER_UNREADABLE,
146
+ }
147
+
148
+ #: What an ending with no code of its own and no row above answers with: the two
149
+ #: chain endings, when the outcome file carried no code the registry knows. « A
150
+ #: chain read did not answer and this client cannot say how it was classified »
151
+ #: is an answer it could not fully read, and it says that rather than guessing
152
+ #: at the transport's word for it.
153
+ UNCLASSIFIED: Final[ProblemCode] = ProblemCode.ANSWER_UNREADABLE
154
+
155
+
156
+ class Ending(NamedTuple):
157
+ """What the harness said about its own ending, as this command reads it back.
158
+
159
+ `code` is the transport's own classification of the chain read that did not
160
+ answer, present only where there was one and the registry carries it.
161
+ """
162
+
163
+ reason: str
164
+ detail: str
165
+ code: ProblemCode | None
166
+
167
+
168
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
169
+ """The options this verb reads, declared beside the code that reads them."""
170
+ parser.add_argument(
171
+ "--pack",
172
+ action="append",
173
+ required=True,
174
+ metavar="DIR",
175
+ help="a pack directory; repeat the option for each pack",
176
+ )
177
+ parser.add_argument("--scope", required=True, help="the scope the questions are asked in")
178
+ parser.add_argument("--socket", required=True, help="the path of the daemon's socket")
179
+ parser.add_argument("--mode", choices=(PER_USER, SYSTEM), default=PER_USER)
180
+ parser.add_argument(
181
+ "--daemon-user",
182
+ default=None,
183
+ help="the account the daemon runs as; a system profile names it",
184
+ )
185
+ parser.add_argument(
186
+ "--principal",
187
+ default=None,
188
+ metavar="REF",
189
+ help="the principal reference to hold grants against; this account by default",
190
+ )
191
+ parser.add_argument(
192
+ "--ungoverned",
193
+ action="store_true",
194
+ help="run the program with nothing in front of it; the chain alone answers",
195
+ )
196
+ parser.add_argument("--json", action="store_true", help="write the envelope instead of prose")
197
+ parser.add_argument(
198
+ "target",
199
+ nargs="*",
200
+ metavar="TARGET",
201
+ help="after `--`: either -m MODULE [args] or SCRIPT [args]",
202
+ )
203
+
204
+
205
+ def run(arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO) -> int:
206
+ """Prove the program, and answer with the code the verdicts imply."""
207
+ for named in arguments.pack:
208
+ try:
209
+ # Read and thrown away. The harness reads every pack again, because
210
+ # it shares no state with this process; reading them here is what
211
+ # makes a pack that will not read the misuse it is, rather than a
212
+ # verification that could not be obtained for a reason the caller
213
+ # would have to go and guess at.
214
+ manifest.read_pack(Path(named))
215
+ except manifest.PackInvalid as invalid:
216
+ # The path as it was TYPED, for the reason `commands.py` gives.
217
+ stderr.write(f"{named}: {invalid}\n")
218
+ return exit_codes.EXIT_MISUSE
219
+ try:
220
+ SocketProfile(
221
+ arguments.socket,
222
+ mode=arguments.mode,
223
+ daemon_user=arguments.daemon_user,
224
+ scope=arguments.scope,
225
+ )
226
+ except ProfileMisuse as invalid:
227
+ # Built and thrown away: the harness builds its own from the same
228
+ # members, and a profile that cannot say what it must verify is a
229
+ # mistake worth reporting before a second interpreter is started.
230
+ stderr.write(f"{invalid}\n")
231
+ return exit_codes.EXIT_MISUSE
232
+ with tempfile.TemporaryDirectory(prefix="sayfirst-verify-") as directory:
233
+ report = Path(directory) / REPORT_FILE
234
+ outcome_file = Path(directory) / OUTCOME_FILE
235
+ configuration = Path(directory) / CONFIGURATION_FILE
236
+ configuration.write_text(
237
+ json.dumps(
238
+ {
239
+ "packs": list(arguments.pack),
240
+ "socket": arguments.socket,
241
+ "mode": arguments.mode,
242
+ "daemon_user": arguments.daemon_user,
243
+ "scope": arguments.scope,
244
+ "principal": arguments.principal,
245
+ "governed": not arguments.ungoverned,
246
+ "report": str(report),
247
+ harness.OUTCOME_FILE_MEMBER: str(outcome_file),
248
+ },
249
+ indent=2,
250
+ sort_keys=True,
251
+ )
252
+ + "\n",
253
+ encoding="utf-8",
254
+ )
255
+ _harness_output(_harness(configuration, list(arguments.target)), stderr)
256
+ document = _findings(report)
257
+ if document is None:
258
+ return _no_answer(outcome_file, arguments, stdout, stderr)
259
+ return _rendered(document, arguments, stdout, stderr)
260
+
261
+
262
+ def _harness_output(finished: subprocess.CompletedProcess[str], stderr: TextIO) -> None:
263
+ """Both of the program's streams, on the diagnostic stream, before the report:
264
+ what it printed comes before what was concluded about it."""
265
+ for written in (finished.stdout, finished.stderr):
266
+ if written:
267
+ stderr.write(written)
268
+
269
+
270
+ def _no_answer(
271
+ outcome_file: Path, arguments: argparse.Namespace, stdout: TextIO, stderr: TextIO
272
+ ) -> int:
273
+ """A run that wrote no findings this command reads, answered from the harness's
274
+ own account of how it ended rather than from the number a shell would see.
275
+
276
+ The number is the TARGET's — the program runs inside the harness's
277
+ interpreter — so a program calling `os._exit(64)` used to choose « refused
278
+ before it began » for a verification that had in fact run. Only
279
+ `INVOCATION_REFUSED` is the invocation's mistake, and only it answers 64,
280
+ which is the code `instrument run` gives the very same mistake; `_EXIT_FOR`
281
+ below says what the other endings answer, and why only one of them is not 4.
282
+ """
283
+ said = _outcome(outcome_file)
284
+ if said is None:
285
+ # The harness wrote neither findings nor an outcome: it did not reach
286
+ # its own first statement, the interpreter never started, or the host
287
+ # killed it. Not a refused invocation and not a conclusion.
288
+ return _answer(
289
+ ProblemCode.ANSWER_UNREADABLE,
290
+ NO_OUTCOME,
291
+ arguments,
292
+ stdout,
293
+ stderr,
294
+ exit_codes.EXIT_COULD_NOT_ASK,
295
+ )
296
+ message = _SAID.get(said.reason, NO_OUTCOME)
297
+ return _answer(
298
+ # The transport's own code wherever the harness carried one, which is
299
+ # both chain endings; the table only for the endings this client is the
300
+ # one that knows about. The sentence is this command's either way — it
301
+ # says which of the harness's endings happened, which no problem code
302
+ # can say — and the detail behind it is the transport's own message.
303
+ said.code if said.code is not None else _NO_ANSWER.get(said.reason, UNCLASSIFIED),
304
+ f"{message}: {said.detail}" if said.detail else message,
305
+ arguments,
306
+ stdout,
307
+ stderr,
308
+ _EXIT_FOR.get(said.reason, exit_codes.EXIT_COULD_NOT_ASK),
309
+ )
310
+
311
+
312
+ #: The code a shell reads for the two endings that are not « the verification
313
+ #: could not be obtained ». An invocation this client refused is the 64
314
+ #: `instrument run` gives the same mistake; findings that exist and will not
315
+ #: read are the 7 `_unreadable` answers for the very same sentence, and the one
316
+ #: `docs/PACKS.md` states — with the outcome file the client now KNOWS the
317
+ #: findings exist, which is what 7 is for. Every other ending is 4.
318
+ _EXIT_FOR: Final[dict[str, int]] = {
319
+ harness.INVOCATION_REFUSED: exit_codes.EXIT_MISUSE,
320
+ harness.REPORTED: exit_codes.EXIT_COULD_NOT_CHECK,
321
+ }
322
+
323
+
324
+ def _outcome(path: Path) -> Ending | None:
325
+ """Which of its own endings the harness said happened, its detail, and its code.
326
+
327
+ `None` for a file that is absent, will not read, or names an ending this
328
+ command does not know — all of which are « the harness said nothing this
329
+ command can use », which is what an absent file already means. A word
330
+ outside the closed vocabulary is not guessed at.
331
+ """
332
+ try:
333
+ document = json.loads(path.read_text(encoding="utf-8"))
334
+ except (OSError, UnicodeDecodeError, ValueError, RecursionError):
335
+ return None
336
+ if not isinstance(document, Mapping):
337
+ return None
338
+ named = document.get("outcome")
339
+ detail = document.get("detail")
340
+ if named not in harness.OUTCOMES or not isinstance(named, str):
341
+ return None
342
+ return Ending(
343
+ named, detail if isinstance(detail, str) else "", _carried(document.get("problem_code"))
344
+ )
345
+
346
+
347
+ def _carried(value: object) -> ProblemCode | None:
348
+ """The transport's own code for this ending, where the registry carries it.
349
+
350
+ A code the registry does not define is not promoted: an unknown is never
351
+ read as more precise than the fallback (article 3), and a code this client
352
+ cannot ask the registry about — for retryability, for its class — is one it
353
+ must not publish as the classification of anything. Asked of the registry's
354
+ own table rather than of a list kept here, so this file holds no codes but
355
+ the three it mints for itself.
356
+ """
357
+ if not isinstance(value, str) or value not in classes_by_code():
358
+ return None
359
+ try:
360
+ return ProblemCode(value)
361
+ except ValueError: # pragma: no cover - the registry and the enum agree
362
+ return None
363
+
364
+
365
+ def _harness(configuration: Path, target: Sequence[str]) -> subprocess.CompletedProcess[str]:
366
+ """Run the harness in an interpreter of its own, and wait for it.
367
+
368
+ `sys.executable` rather than a name looked up on the path: the harness is
369
+ this distribution's own module, and the interpreter that can import it is
370
+ the one running this command.
371
+
372
+ A timeout is not a verdict. It comes back as a run that wrote no findings,
373
+ which this command reports as a verification that did not conclude.
374
+ """
375
+ try:
376
+ return subprocess.run(
377
+ [sys.executable, "-m", HARNESS_MODULE, str(configuration), "--", *target],
378
+ capture_output=True,
379
+ text=True,
380
+ timeout=HARNESS_TIMEOUT,
381
+ check=False,
382
+ )
383
+ except subprocess.TimeoutExpired as expired:
384
+ return subprocess.CompletedProcess(
385
+ expired.cmd,
386
+ returncode=exit_codes.EXIT_COULD_NOT_ASK,
387
+ stdout=_text(expired.stdout),
388
+ stderr=_text(expired.stderr)
389
+ + f"the program had not ended after {HARNESS_TIMEOUT:g} seconds\n",
390
+ )
391
+
392
+
393
+ def _text(value: object) -> str:
394
+ """What a timeout kept of a stream, which may be bytes, text or nothing."""
395
+ if isinstance(value, bytes):
396
+ return value.decode(errors="replace")
397
+ return value if isinstance(value, str) else ""
398
+
399
+
400
+ def _findings(report: Path) -> Mapping[str, object] | None:
401
+ """The harness's report, or `None` when it wrote none this command can read.
402
+
403
+ A report that is absent and a report that will not parse are the same fact
404
+ here — there are no findings — and neither is turned into a verdict.
405
+ """
406
+ try:
407
+ document = json.loads(report.read_text(encoding="utf-8"))
408
+ except (OSError, UnicodeDecodeError, ValueError, RecursionError):
409
+ return None
410
+ if not isinstance(document, Mapping) or reads.too_deep(document):
411
+ return None
412
+ return document
413
+
414
+
415
+ def _rendered(
416
+ document: Mapping[str, object],
417
+ arguments: argparse.Namespace,
418
+ out: TextIO,
419
+ err: TextIO,
420
+ ) -> int:
421
+ """One line per point, then what was inspected and how the program ended.
422
+
423
+ A point that carries an `unjudged` count gets a second line of its own
424
+ rather than a word folded into its first. The verdict is about the events
425
+ the run judged and stays exactly that; the count is the effect the run
426
+ could NOT judge, and a reader has to be able to see both — which is why it
427
+ is a line and not a suffix, and why `_exit_for` reads it rather than
428
+ inferring it from the verdict.
429
+ """
430
+ points = document.get("points")
431
+ if not isinstance(points, list):
432
+ return _unreadable(arguments, out, err)
433
+ verdicts: list[str] = []
434
+ unjudged: list[int] = []
435
+ lines: list[str] = []
436
+ for point in points:
437
+ if not isinstance(point, Mapping) or point.get("verdict") not in harness.VERDICTS:
438
+ return _unreadable(arguments, out, err)
439
+ counted = point.get(harness.UNJUDGED)
440
+ # Required and required to be a count. A report of this distribution's
441
+ # own harness always carries it; one that does not, or carries something
442
+ # that is not a number of events, is findings this client cannot read —
443
+ # which is answered as « could not check » and never as a verdict.
444
+ if not isinstance(counted, int) or isinstance(counted, bool) or counted < 0:
445
+ return _unreadable(arguments, out, err)
446
+ verdicts.append(str(point["verdict"]))
447
+ unjudged.append(counted)
448
+ lines.append(
449
+ f"{point['verdict']} {point.get('pack')} {point.get('module')}."
450
+ f"{point.get('attribute')} {point.get('capability')} "
451
+ f"events={point.get('events')}"
452
+ )
453
+ if counted:
454
+ lines.append(
455
+ f"{harness.UNJUDGED}: {counted} {point.get('pack')} {point.get('module')}."
456
+ f"{point.get('attribute')} {point.get('capability')} — an effect named the "
457
+ f"program's own start file after it had started, so this run could not "
458
+ f"judge it and is not a pass (article 2)"
459
+ )
460
+ if arguments.json:
461
+ render.write_json(
462
+ render.envelope(CONTRACT_GENERATION, _verification(document), result=dict(document)),
463
+ out,
464
+ )
465
+ else:
466
+ for line in lines:
467
+ out.write(f"{line}\n")
468
+ inspected = document.get("inspected")
469
+ named = " ".join(str(name) for name in inspected) if isinstance(inspected, list) else ""
470
+ out.write(f"inspected: {named or render.NOT_STATED}\n")
471
+ ending = document.get("target_exit")
472
+ out.write(f"target exit: {'none' if ending is None else ending}\n")
473
+ return _exit_for(verdicts, unjudged)
474
+
475
+
476
+ def _verification(document: Mapping[str, object]) -> Mapping[str, object]:
477
+ """What the harness verified about the far end, or that it verified nothing."""
478
+ verification = document.get("verification")
479
+ if isinstance(verification, Mapping):
480
+ return verification
481
+ return render.verification_document(None, None, False)
482
+
483
+
484
+ def _unreadable(arguments: argparse.Namespace, out: TextIO, err: TextIO) -> int:
485
+ """A report whose findings this command cannot read is no proof at all.
486
+
487
+ Never a verdict: the findings exist and this client could not read them,
488
+ which is a check that did not conclude rather than a program found wanting.
489
+ """
490
+ return _answer(
491
+ ProblemCode.ANSWER_UNREADABLE,
492
+ UNREADABLE_FINDINGS,
493
+ arguments,
494
+ out,
495
+ err,
496
+ exit_codes.EXIT_COULD_NOT_CHECK,
497
+ )
498
+
499
+
500
+ def _answer(
501
+ code: ProblemCode,
502
+ message: str,
503
+ arguments: argparse.Namespace,
504
+ out: TextIO,
505
+ err: TextIO,
506
+ exit_code: int,
507
+ ) -> int:
508
+ """A verification that concluded nothing, said the way this client says a problem.
509
+
510
+ Under `--json` it is the envelope every other read writes for a problem, on
511
+ **stdout** — the module docstring says why it is not the error stream here,
512
+ and nothing else is written to stdout on any of these paths. In prose it is
513
+ the sentence on the error stream, which is what a person reads beside the
514
+ program's own output. The verification document says nothing was verified
515
+ about a far end, because this process opened no connection: the harness's
516
+ own verification is carried in a report, and there is no report.
517
+ """
518
+ document = Problem(code, message, problem_retryable(code), CONTRACT_GENERATION).to_document(
519
+ CONTRACT_GENERATION
520
+ )
521
+ if arguments.json:
522
+ render.write_json(
523
+ render.envelope(
524
+ CONTRACT_GENERATION,
525
+ render.verification_document(None, None, False),
526
+ problem=document,
527
+ ),
528
+ out,
529
+ )
530
+ else:
531
+ err.write(f"{message}\n")
532
+ return exit_code
533
+
534
+
535
+ def _exit_for(verdicts: Sequence[str], unjudged: Sequence[int] = ()) -> int:
536
+ """Zero only when every point was governed and nothing went unjudged.
537
+
538
+ A run with no point at all cannot conclude either: it would be the
539
+ verification that inspected nothing and reported green, which article 9
540
+ names as the thing the public gate has to fail on.
541
+
542
+ An `unjudged` count answers « could not check » and takes precedence over
543
+ zero, never over a finding: an effect this run could not judge leaves the
544
+ proof incomplete, which is what 7 says, while 6 stays reserved for a
545
+ finding this client actually made. A run cannot be a pass with one of
546
+ these outstanding — that is the whole of the rule, whose measured shape
547
+ before it existed was exit 0 and `governed` over an effect nobody decided.
548
+ """
549
+ if harness.UNGOVERNED in verdicts:
550
+ return exit_codes.EXIT_CHECK_FAILED
551
+ if not verdicts or harness.NOT_EXERCISED in verdicts or any(unjudged):
552
+ return exit_codes.EXIT_COULD_NOT_CHECK
553
+ return exit_codes.EXIT_ALLOW
sayfirst_cli/main.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The `sayfirst` command tree, for asking and reading what was decided.
3
+
4
+ The constitution names `sayfirst` as one command with subcommands, and one
5
+ console script points at one module. This distribution is the product client
6
+ (article 14), so it installs the script and dispatches; a later slice adds a
7
+ subcommand by adding it to the dispatch, never by claiming a second script.
8
+
9
+ `--help` lists exactly the commands this distribution answers, because a help
10
+ text that advertises an absent command is a claim without evidence (article 2).
11
+ It is answered from the table below and imports nothing, so the claim can be
12
+ read on a machine where the control plane's contract distribution is not
13
+ installed — which is where it is most likely to be asked.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import importlib
20
+ import sys
21
+ from collections.abc import Callable, Sequence
22
+ from typing import Final, TextIO
23
+
24
+ #: The subcommands this distribution answers, each with the module that owns its
25
+ #: parser and its exit codes, and whose `main` is its entry point. A slice adds
26
+ #: a line here.
27
+ #
28
+ # Named rather than imported, because `--help` is a claim about what this tool
29
+ # answers and answering it must not need the control plane's contract
30
+ # distribution installed: importing every subcommand up front made
31
+ # `python -m sayfirst_cli.main --help` a traceback wherever the contract is
32
+ # absent, and turned a reduced run's honest « not run » into failures (article
33
+ # 2's rule about an absence rendered as a negative fact, inverted).
34
+ COMMANDS: Final[dict[str, str]] = {
35
+ "ask": "sayfirst_cli.ask",
36
+ "trace": "sayfirst_cli.trace",
37
+ "explain": "sayfirst_cli.explain",
38
+ "evidence": "sayfirst_cli.evidence",
39
+ "approvals": "sayfirst_cli.approvals",
40
+ "instrument": "sayfirst_cli.instrument.commands",
41
+ "packs": "sayfirst_cli.packs_cmd",
42
+ }
43
+
44
+
45
+ def entry_point(name: str) -> Callable[..., int]:
46
+ """The `main` of one subcommand, imported when it is about to run and not before."""
47
+ return importlib.import_module(COMMANDS[name]).main
48
+
49
+
50
+ def build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(
52
+ prog="sayfirst",
53
+ description=("Ask the sayfirst control plane before an effect, and read what it decided."),
54
+ )
55
+ commands = parser.add_subparsers(dest="command", required=True)
56
+ for name in COMMANDS:
57
+ # Declared so `--help` names every subcommand this tool answers. Each
58
+ # subcommand's own parser reads its arguments, after the dispatch below.
59
+ commands.add_parser(name, add_help=False)
60
+ return parser
61
+
62
+
63
+ def main(
64
+ argv: Sequence[str] | None = None, *, out: TextIO | None = None, err: TextIO | None = None
65
+ ) -> int:
66
+ forwarded = list(sys.argv[1:] if argv is None else argv)
67
+ if forwarded and forwarded[0] in COMMANDS:
68
+ return entry_point(forwarded[0])(forwarded[1:], out=out, err=err)
69
+ build_parser().parse_args(forwarded)
70
+ raise AssertionError("argparse accepted a command that has no entry point")
71
+
72
+
73
+ def run() -> None:
74
+ raise SystemExit(main())
75
+
76
+
77
+ if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
78
+ run()
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Where the convenience packs this distribution ships live (article 9).
3
+
4
+ A directory of pack directories and nothing else: no loader here reads any of
5
+ them by name. `sayfirst instrument run --pack DIR` designates one by its path,
6
+ and `sayfirst packs list` reads this directory only to print that path back —
7
+ this file exists so `importlib.resources` resolves the directory as an
8
+ ordinary subpackage instead of a namespace package assembled from wherever it
9
+ is found on the path, which is one fewer thing a reader has to reason about.
10
+ """
@@ -0,0 +1,4 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ This is a convenience pack (article 9). A competent engineer would rebuild it in
3
+ a day from `sqlite3`'s public documentation: one attribute, one call shape,
4
+ one argument that names the effect. Classified on 2026-09-15.