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
`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
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
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.
`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
and owns no flag that changes a bundle's bytes: the build runs on THIS
package's default, so a project bundle and an `okf build` bundle of the same
folder at the same stamp are the same bytes.
a person needs in order to know what they just got. It adds no rule of its
own: the build runs on THIS package's defaults, so a project bundle and an
`okf build` bundle of the same 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
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 . import consume, skill
from .cli import DEFAULT_STAMP, build
from .corpus import CorpusReport
from .cli import DEFAULT_GATE, DEFAULT_STAMP, build
from .corpus import GATE_NAMES, CorpusReport
from .errors import IngestError
from .inbox import walk_inbox
from .profiles import SEGMENTED_OKF_V0_2
@ -176,6 +184,7 @@ def create(
out: Path,
bundle_id: str | None = None,
ingested_at: str = DEFAULT_STAMP,
gate: str = DEFAULT_GATE,
force: bool = False,
) -> tuple[Path, Path, str]:
"""Build the bundle, generate the skill, return both paths and the summary.
@ -191,6 +200,7 @@ def create(
ingested_at=ingested_at,
bundle_id=identity,
okf_version=PROJECT_OKF_VERSION,
gate=gate,
)
if report.conservation_failed:
raise IngestError(
@ -230,6 +240,16 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
default=DEFAULT_STAMP,
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(
"--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,
bundle_id=args.bundle_id,
ingested_at=args.ingested_at,
gate=args.gate,
force=args.force,
)
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.
The command adds no rule and owns no flag that changes a bundle's bytes, so
these tests are mostly about that: the project bundle must be the SAME bytes
`okf 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.
The command adds no rule, and owns exactly ONE flag that changes a bundle's
bytes -- `--gate`, which is a screen and not a segmentation rule. These tests
are mostly about the rest: the project bundle must be the SAME bytes `okf
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
@ -267,3 +268,36 @@ def test_a_sheet_reaches_the_project_bundle_as_it_reaches_the_build_command(
== 0
)
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"