feat(project): --gate reaches the build, and the bundle says which one

`project.create` called `build()` with five keyword arguments and no `gate=`,
so `okf project` screened by the package default and nothing anywhere said
that was a choice rather than the only option. The gate is not a segmentation
rule -- it is a screen about whether a document may be persisted at all -- so
it is the one flag this command owns that may move a bundle's bytes, and the
module docstring, the README paragraph and the test file's own claim are
corrected rather than left standing beside the new flag.

The default is `okf build`'s default, so an unflagged `okf project` is the
bytes it always was; the byte-equality invariant against `okf build` holds
unchanged. An unknown gate name still raises `gate_invalid` rather than
falling back -- a fallback reproduces the defect with an extra step.

Tests first, all three red: the gate name read back out of the bundle's own
`log.md` (not out of the call), the flag parsed by this command's parser, and
the unknown name refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-20 23:08:50 +02:00
commit 1c97e57212
3 changed files with 66 additions and 11 deletions

View file

@ -1409,7 +1409,7 @@ No security functionality is reimplemented here.
`--gate` takes `guard-trusted-source` (the default), `guard-user-upload` or `--gate` takes `guard-trusted-source` (the default), `guard-user-upload` or
`none`, and the name is written into the bundle's `log.md` either way, so a `none`, and the name is written into the bundle's `log.md` either way, so a
consumer holding a bundle can tell a screened one from an unscreened one consumer holding a bundle can tell a screened one from an unscreened one
without asking. `okf project` has no such flag and takes the default. without asking. `okf project` takes the same `--gate` with the same default: it is the one flag there that may move a bundle's bytes, and it is there because a command that cannot reach the gate screens by a default nothing said was a choice.
That paragraph is new, and the sentence above it was true of our own command That paragraph is new, and the sentence above it was true of our own command
until 2026-09-15: `okf build` injected a permissive stub and no argument until 2026-09-15: `okf build` injected a permissive stub and no argument

View file

@ -1,10 +1,18 @@
"""One folder of documents in, one questionable project out, in one command. """One folder of documents in, one questionable project out, in one command.
`okf project <folder>` is `okf build` followed by `okf skill`, plus the summary `okf project <folder>` is `okf build` followed by `okf skill`, plus the summary
a person needs in order to know what they just got. It adds no rule of its own a person needs in order to know what they just got. It adds no rule of its
and owns no flag that changes a bundle's bytes: the build runs on THIS own: the build runs on THIS package's defaults, so a project bundle and an
package's default, so a project bundle and an `okf build` bundle of the same `okf build` bundle of the same folder at the same stamp are the same bytes.
folder at the same stamp are the same bytes.
**One flag here DOES move a bundle's bytes, and it is stated rather than
implied: `--gate`.** Every other flag `okf build` owns is deliberately absent,
for the reason above -- two build paths would leave every measurement report
pinned to a bundle nobody produces. The gate is different in kind: it is not a
rule about how a document is cut but a screen about whether a document may be
persisted at all, and a command that cannot reach it screens by the package
default while saying nothing about it. The default is `okf build`'s default,
so an unflagged `okf project` is the bytes it always was.
**Why a third command rather than a documented three-step.** The three-step **Why a third command rather than a documented three-step.** The three-step
existed and was measured on a reader: set `PYTHONPATH`, take a snapshot of a existed and was measured on a reader: set `PYTHONPATH`, take a snapshot of a
@ -34,8 +42,8 @@ import unicodedata
from pathlib import Path from pathlib import Path
from . import consume, skill from . import consume, skill
from .cli import DEFAULT_STAMP, build from .cli import DEFAULT_GATE, DEFAULT_STAMP, build
from .corpus import CorpusReport from .corpus import GATE_NAMES, CorpusReport
from .errors import IngestError from .errors import IngestError
from .inbox import walk_inbox from .inbox import walk_inbox
from .profiles import SEGMENTED_OKF_V0_2 from .profiles import SEGMENTED_OKF_V0_2
@ -176,6 +184,7 @@ def create(
out: Path, out: Path,
bundle_id: str | None = None, bundle_id: str | None = None,
ingested_at: str = DEFAULT_STAMP, ingested_at: str = DEFAULT_STAMP,
gate: str = DEFAULT_GATE,
force: bool = False, force: bool = False,
) -> tuple[Path, Path, str]: ) -> tuple[Path, Path, str]:
"""Build the bundle, generate the skill, return both paths and the summary. """Build the bundle, generate the skill, return both paths and the summary.
@ -191,6 +200,7 @@ def create(
ingested_at=ingested_at, ingested_at=ingested_at,
bundle_id=identity, bundle_id=identity,
okf_version=PROJECT_OKF_VERSION, okf_version=PROJECT_OKF_VERSION,
gate=gate,
) )
if report.conservation_failed: if report.conservation_failed:
raise IngestError( raise IngestError(
@ -230,6 +240,16 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
default=DEFAULT_STAMP, default=DEFAULT_STAMP,
help=f"stamped verbatim. Default {DEFAULT_STAMP}: deterministic, never the clock", help=f"stamped verbatim. Default {DEFAULT_STAMP}: deterministic, never the clock",
) )
parser.add_argument(
"--gate",
choices=GATE_NAMES,
default=DEFAULT_GATE,
help=(
"the persist gate every concept body passes before it is written, "
f"as `okf build` takes it. Default {DEFAULT_GATE}. `none` screens "
"NOTHING; the name is written into the bundle's log.md either way"
),
)
parser.add_argument( parser.add_argument(
"--force", action="store_true", help="replace an existing SKILL.md at the destination" "--force", action="store_true", help="replace an existing SKILL.md at the destination"
) )
@ -248,6 +268,7 @@ def main(argv: list[str] | None = None) -> int:
out=out, out=out,
bundle_id=args.bundle_id, bundle_id=args.bundle_id,
ingested_at=args.ingested_at, ingested_at=args.ingested_at,
gate=args.gate,
force=args.force, force=args.force,
) )
except (IngestError, consume.ConsumeError, skill.SkillError) as exc: except (IngestError, consume.ConsumeError, skill.SkillError) as exc:

View file

@ -1,9 +1,10 @@
"""`okf project`: one folder in, one bundle plus one skill out. """`okf project`: one folder in, one bundle plus one skill out.
The command adds no rule and owns no flag that changes a bundle's bytes, so The command adds no rule, and owns exactly ONE flag that changes a bundle's
these tests are mostly about that: the project bundle must be the SAME bytes bytes -- `--gate`, which is a screen and not a segmentation rule. These tests
`okf build` writes for the same folder at the same stamp, or there are two are mostly about the rest: the project bundle must be the SAME bytes `okf
build paths and the reports are pinned to one of them. build` writes for the same folder at the same stamp, or there are two build
paths and the reports are pinned to one of them.
""" """
from __future__ import annotations from __future__ import annotations
@ -267,3 +268,36 @@ def test_a_sheet_reaches_the_project_bundle_as_it_reaches_the_build_command(
== 0 == 0
) )
assert tree(bundle) == tree(reference) assert tree(bundle) == tree(reference)
def test_the_gate_reaches_the_build_and_the_bundle_says_which_one(
folder: Path, tmp_path: Path
) -> None:
"""`okf project --gate` is the one flag here that MAY move a bundle's bytes.
`project.create` called `build()` with five keyword arguments and no
`gate=`, so the gate name was unreachable from this command: every project
bundle was screened by the package default and nothing said so was a
choice. The gate's name is written into the bundle's own `log.md`, so the
check is the bundle's, not the call's.
"""
out = tmp_path / "project"
bundle, _, _ = project.create(folder, out=out, gate="none")
log = (bundle / "log.md").read_text(encoding="utf-8")
assert "NOTHING WAS SCREENED" in log
default = tmp_path / "default"
other, _, _ = project.create(folder, out=default)
assert "NOTHING WAS SCREENED" not in (other / "log.md").read_text(encoding="utf-8")
def test_the_gate_flag_is_parsed_by_the_project_command(folder: Path, tmp_path: Path) -> None:
args = project.parse_args([str(folder), "--out", str(tmp_path), "--gate", "none"])
assert args.gate == "none"
def test_an_unknown_gate_name_does_not_start_the_run(folder: Path, tmp_path: Path) -> None:
"""A fallback would reproduce the defect the gate was added to close."""
with pytest.raises(IngestError) as caught:
project.create(folder, out=tmp_path / "project", gate="guard-nonesuch")
assert caught.value.code == "gate_invalid"