Commit graph

34 commits

Author SHA1 Message Date
17c49fc04b feat(consume): give every excerpt the name and the address an answer must cite
The pre-pass delivered the right concept and the answer could not name it.
Measured by portfolio-optimiser 2026-09-08 over three paid arms: the gold
concept came back at rank 1 of 8 on 3 of 3 bundles, and the model answered
correctly on 1 of 3, because a delivered excerpt carried `concept_id`, body
text and nothing the document is known by. The previous session measured the
same gap from the other side: the provenance it had just written into every
concept did not reach the payload at all.

`excerpt_for` now carries `title` unconditionally, and `req_number`, the SPEC
5.1 address `sources` and each locator key (`source_pages`, `source_sheet`,
`source_rows`, `source_lines`, `source_offset`) when the concept has them. A key
the producer did not write stays absent: an empty value would assert that they
wrote an empty one, which is the contract's 6.4 failure.

`sources` is read in BOTH YAML forms, on a measurement rather than a taste. K2
writes the flow form on 629 of 629 concepts; the largest N-bundle writes the
block form on 270 of 270 and carries no locator key at all, so a flow-only
reader delivers that bundle with no address whatsoever. Reading the block form
is not a licence to write it - the emission rule is untouched, because the
line-oriented parser still cannot round-trip a block list. A `sources` value
this reader cannot decode is named (`sources_unreadable`), never dropped into
the same silence as an absent one.

Contract 8 gains the requirement and the checker gains its code
(`excerpt_unnamed`, 15 rules now, was 14): an excerpt a reader cannot name is
one an answer cannot cite, whatever its rank. `req_number`, `sources` and the
locators are SHOULD, not MUST - they are conditional on the producer, and a
bundle whose concepts carry no identifier cannot deliver one.

K2 controls, same question and same k, before against a frozen copy of the tool
at b6a8c8b: the RANKING does not move - the same 8 concept ids in the same
order, identical `text_sha256`, identical `withheld`, identical denominators
(629 = 621 + 8). The FIELD is what moved: payload 108 877 -> 111 744 B
(+2.63 %), budget spent 18 606 -> 20 907 (+287.6 B per excerpt), excerpt
members 9 -> 15, 83 changed lines. The contract document's own bytes moved with
8, so the budget instrument's known-positive moves with it: 10 349 -> 12 049
measured, 10 060 -> 11 719 raw, delta 289 -> 330.

New fixture `tests/fixtures/consume-provenance`: the two address forms and a
concept carrying neither address nor identifier. Purpose-built, because the two
real bundles are complementary and neither exercises both forms.

Suite 1347 (1339 before), ruff clean, mypy src clean.

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 15:01:41 +02:00
b6a8c8bd89 feat(inbox): point every concept at the document it came from, with a locator per format
A concept named its source file by basename and, when segmented, carried a
`source_offset` into the text THIS LIBRARY extracted. Following that pointer
needed the corpus directory, the extractor and its exact transitive version --
none of which the bundle carries. Hand-walked on a real K2 concept: six steps,
four of them requiring knowledge from outside the bundle, to learn that a
requirement sits on pages 12-13 of a 20-page document.

The address is spec's: `sources: [{ resource, title }]`, where `resource` is
the dropped file's inbox-relative path (SPEC v0.2 5.1:303-306 -- "an absolute
URL, a bundle-relative path, or a path into a `references/` subdirectory").
The locator is ours, and it has to be: 5.1 has no field for a place within a
resource, and the pinned guard (1.3.0) rejects every route to putting one
inside a `sources` entry -- a non-allowlisted key by name, a nested flow list
as "scalar leaves only", and quoting as an unsupported form. So the locator is
top-level keys shaped like `source_offset`, and a path carrying a flow
terminator is refused fail-fast rather than mangled.

The unit table is built AT EXTRACTION, where the extracted text and the
original's structure are known to agree: pdf -> `source_pages` from
pdfplumber's own page numbers (a page that yielded no text does not renumber
the ones after it), xlsx -> `source_sheet` + `source_rows`, everything else ->
`source_lines`. `source_offset` stays.

Two measurements changed the design before it shipped. A `paragraphs` key for
docx would name a number the document does not have: `<w:p>` counts of
108/27/65/176/57 against converted-markdown lines of 75/33/67/144/63, not one
pair agreeing -- so the key is `source_lines` and says what it indexes. And an
empty spreadsheet row renders exactly like a table separator: the content-based
rule ate 8 empty rows on the K2 price sheet and reported its last row as 92
against a workbook that says 100. The separator is now found by position, and
`tomrad.xlsx` keeps that red.

One profile moves. `provenance` is a policy object, `None` everywhere but
`SEGMENTED_OKF_V0_2`; the other five shipped profiles are byte-identical.

K2 rebuilt from a frozen src copy: 629 concepts, 1108 files, name set identical,
0 ids moved, 479 files byte-identical, 629 changed and 0 lines removed anywhere.
629/629 now carry an address and a locator. New ref
`sha256-tree:665563a2f74423fcbcc8e4f0b0954ee73b73985ac0418de4f6987bd162a1f7c8`;
`2f82fcfe...` is stale. The pre-pass payload does not grow by one byte
(209 092 B before and after, 18 changed lines: the ref and eight per-concept
digests) -- because an excerpt carries the body, not the frontmatter, which is
also why the consumer still cannot cite "file X page 12" from a payload alone.

Report: docs/2026-09-08-proveniens-k2.md. 1339 tests, ruff and mypy clean.

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 14:39:24 +02:00
116d3e1007 feat(consume): weight a lexical hit by its rarity, off by default and measured
O2b asked whether a requirement number can be made worth more than a common
word by weighting each hit with the token's rarity in the bundle, with no
hand-set constant and no declared token class. It can, on one of the three
bundles, and the two it cannot are decomposed rather than guessed.

The rule is log(N/df) over the concepts' own tokens, counted with the same
four-character prefix rule a hit is scored with. It enters the RANKING and
never the GATE: `lexical` stays a count, because `krav` weighs exactly 0 on
all three bundles and a weighted gate would drop every concept matching only
that word -- which is the gate 54a0bc2 falsified for other reasons. One df
table per bundle reaches every stage that scores the question against text,
including the document prior. One pass, 0.241 s over 1 133 concepts.

Measured on four corpora, before and after, with every published figure
reproduced first: gold fused rank 96 -> 103, 9 -> 8 (withheld -> DELIVERED at
rank 8) and 35 -> 35; K2's priced sheet candidate rank 10 -> 2 with the cost
vocabulary and 251 -> 78 without; Q-good unmoved at rank 1; hit@8 5 of 6 with
every rank identical; the S7 control payload byte-identical on the default
command.

DEFAULT OFF, decided by the number and not by taste: it does not win on all
four, because N100's gold loses seven rank positions. Off means the bytes that
were already published, and that is measured -- 8 of 8 payload digests
identical against a frozen copy of 56c1205 built with git archive.

Two limits, both someone else's mechanism and both named: MIN_SHARED_PREFIX=4
makes a unique identifier read as 135-of-446 common on N100, so the weight
correctly ranks a common adjective above the exact requirement number; and RRF
consumes RANKS only, so on N500 -- where the gold already leads the one signal
that can see the identifier, and the other two cannot see it at all -- no
weighting inside a signal can move anything.

Consumption-side only, so no rebuild: the K2 bundle ref 2f82fcfe... stands.

Report: docs/2026-09-08-sjeldenhetsvekt.md. 13 new tests, red first; 8
mutations, 8 red, two of them only after the survivors were read as code -- one
exposed a fixture that put the identifier where the real corpus does not, and
the corrected fixture is what found the RRF limit. Suite 1295 -> 1308.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:36:57 +02:00
56ae274246 fix(extract,build): write a spreadsheet as pipe tables, stop linking the run log from the index
Two producer-side findings from the consumer's S7c acid test (ordre 20260908T063454Z-3648220855-from-.claude), both measured on K2 before and after, both with the corpus rebuilt from scratch.

FUNN 3 -- THE FORM. The converter's default markdown writer emits simple tables, which pad every cell out to the width of the widest cell in its column. Measured on the tender's price sheet: one 594-character prose cell produced a 67 244-character whitespace carpet with runs of up to 887 characters between a label and its amount, 19 integral amounts carrying a converter `.0`, and a header row naming one column. The bytes reached a live model in 2 of 11 prompts and 0 of 11 answers. The spreadsheet row now writes pipe tables with `--columns=1` (load-bearing: the pipe writer pads to a width computed from it, so at the default 72 a narrow table gains runs of up to 45). Same sheet after: 11 048 characters, longest run 2, one row per line, 0 artificial `.0`. Spreadsheet-only, and the scoping is pinned by three digests -- the same change moves the odt fixture 1366 -> 1105, so it can fail.

The `.0` rewrite is bounded twice: to a cell whose whole content is such a number, anchored between unescaped pipes, and skipped when the literal is in the workbook's shared string table -- the converter renders the number 92 and the TEXT "92.0" identically, so the output alone cannot tell them apart. Read with zipfile and xml.etree; no new dependency.

FUNN 2 -- THE LOG LINK. `link_log_in_root_index` (95eb271) is removed. Consumption contract SS 9.2 forbids a consumer from enumerating the bundle directory unless the profile says the index is derived, so the index tree is the entire map a consumer may use and everything it links is a document: their navigator returned 630 where our pre-pass counts 629, and a corpus run's own log was citable as content. The log is still written to the bundle root (SPEC section 9); `tools/okf_consume.py` keeps its exclusion for the bundles already built with the link.

K2 rebuilt twice. BEFORE reproduces the consumer's ref exactly (`sha256-tree:f14872a0...c8a92a`, 629 concepts) and their three consume figures to the token (57 289 / 62 149 / 58 401). AFTER: 629 concepts, `merged + coded rejections = 43 = N`, new ref `sha256-tree:c26eed6a...e3261f`, 627 of 629 concepts byte-identical, 1104 of 1108 files identical to the delivered bundle.

ONE REGRESSION, MEASURED AND NOT FIXED: on the mandate-shaped question with the vocabulary bridge the priced concept moves from candidate rank 10 to 19, so `--k 12` withholds it `below_k`; `--cost-vocabulary --k 20` delivers it at 65 912 o200k. The cause is measured rather than argued -- restoring only the concept's title on the new short body ranks it 10 again. The chain ends at the orphan check (`propose.py:461`), which drops the sheet heading once a table block opens two lines below it. That is the already-reported orphan gate, and changing it is a default-ON segmentation rule affecting every document type. The specific question is unaffected: rank 1 before and after. The priced excerpt's budget share falls from 56.5 % to 9.7 %.

11 new tests (RED first), 8 mutations, 8 red, with an unmutated control green each time. One mutation survived twice before the fixture could make it fire, and both survivals are written down. 1279 -> 1287 tests. mypy --strict clean on 28 files. ruff clean. Both proposer goldens byte-unchanged. One frozen literal moved with the fix and is reported rather than hidden.

Report: docs/2026-09-08-prisform-og-loggen-k2.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:06:58 +02:00
6776c37d23 feat(consume): measure the budget lock, add one flag-gated top-rank reservation
The prior measurement (docs/2026-09-08-blindsone-below-k-k2.md SS 3) found that
the budget, not the ranking, is the second lock on a mandate-shaped cost
question -- and that the same mechanism was a REGRESSION on the question that
works: raising `--k` to 16 evicted the gold concept, because the exact knapsack
maximises a SUM of fused scores and has no opinion about rank, so twenty small
excerpts out-value one that costs 56.5 % of the budget.

Measured here on the same 629-concept bundle, with the three known-positive
figures from `4c699fd` reproduced first:

- Corpus distribution, denominator 629: median excerpt 857 B, max 223 391 B,
  3 concepts over the limit alone.
- Candidate rule (b), a corpus-derived budget, is FALSIFIED by two numbers: two
  defensible derivations are 49x apart on the same corpus, the small one turns
  the gold concept into `over_budget_alone` (13 refusals against 2), the large
  one changes nothing at the default k. A budget is the consumer's constraint,
  not a property of the corpus; `--limit` already belongs to the caller.
- Built instead, behind `--reserve-top-rank` (default OFF): the top-ranked
  candidate gets its bytes before the pack runs, AFTER the `over_budget_alone`
  pre-exclusion and never before, and the payload declares `budget.reserved`.
- It fixes the eviction: k=16 and k=24 deliver the gold concept at rank 1,
  costing one and two excerpts, and 20.4 % / 27.3 % FEWER o200k tokens.
- It changes the delivered list in 2 of 24 measured combinations -- both of them
  that eviction. In the other 22 the list, its order and `spent` are identical.
- It does NOT close the mandate-shaped blind spot: that concept ranks 10, not 1.
  The one delivering command is `--cost-vocabulary --k 12 --limit 160000`
  (62 149 tokens against 58 401), and that is a consumer's decision.

11 new tests (RED first), 7 mutations 7 red with an unmutated negative control
green before and after; two of the seven survived the first test set and the
tests were strengthened. Default payload byte-identical, both goldens unchanged.

Report: docs/2026-09-08-blindsone-laas2-budsjett-k2.md
Suite 1279 green, mypy --strict clean over 28 files, ruff clean.

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 05:18:30 +02:00
4c699fdbb1 feat(consume): measure the below-k blind spot, add one flag-gated vocabulary bridge
The consumer report (portfolio-optimiser, S7 SS 2) found that a mandate-shaped
cost question withheld the corpus's one priced table under `below_k`. Measured
here, on a bundle proven byte-identical to a fresh HEAD rebuild:

- The mechanism is a VOCABULARY gap, not a `k` defect: two of three ranking
  signals are exactly 0.0 and the concept is candidate 249 of 269.
- The k-sweep buys nothing: k in {8,12,16,24,32,64,128} all withhold it, at
  +9.5 % tokens. It also found a regression -- for the question that WORKS,
  k >= 16 EVICTS the gold concept, because one 67 838 B excerpt is 56.5 % of
  the budget and the knapsack maximises a sum.
- Two proposed rules were falsified BEFORE any code: number/table density ranks
  the priced table 178/165/46 of 269 (the form is unfilled, so it is
  number-poor), and per-document spread puts its document 30th of 35.

Built instead, behind `--cost-vocabulary` (default OFF, DEFAULT byte-identical):
one declared cost/price/quantity vocabulary family that bridges a question and a
document naming money with different words. It moves the concept from candidate
rank 249 to 10 -- and does NOT close the blind spot: the budget still refuses
it, which is now a separately measured second lock.

Seven RED tests first; six mutations of the rule, six red (two survived the
first version of the tests and the tests were strengthened). Control: a question
with no cost term produces a byte-identical payload with the flag on, at every
k, on the real corpus. Known-positive: 164 987 B / 40 425 o200k tokens, equal to
the published pair.

Report: docs/2026-09-08-blindsone-below-k-k2.md
Suite 1268 green, mypy --strict clean over 28 files, both goldens unchanged.

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 04:55:45 +02:00
51735fa7a8 feat(consume): instantiate the K2 consumption skill, and document the pre-pass
Step 12's README section is brought forward to here because the docs gate is
right: a feat commit that ships a new command needs the command documented.
CLAUDE.md's Commands section gains the pre-pass beside `okf build`. Nothing
else moves.

Contract check against a real payload from the 629-concept bundle:

  $ .venv/bin/python tools/okf_consume.py <K2-bundle> \
      --question 'Hvordan skal prisene fylles ut?' --out /tmp/k2.json
  $ .venv/bin/python tools/okf_contract_check.py \
      --skill skills/okf-consume/SKILL.md --payload /tmp/k2.json
  conformant: 14 rules over 8 excerpts and 621 withheld entries, 0 findings
  exit=0

And the two negative controls, because a green checker proves little on its
own -- measured, it returns 0 findings on an empty payload paired with the
unfilled template:

  broken denominator identity -> NOT conformant, 2 findings, exit=1
  missing payload file        -> exit=2

Placeholder scan, known-positive first: the DOTALL scan reports 20 occurrences
on the template and 0 on this copy. The shipped example payload is generated
from the in-repo golden bundle, not from the corpus, and a test regenerates it
byte for byte. No K2 concept path or document title reaches any tracked file
here, checked with a pattern shown able to find against the bundle's own index.

Suite run after git add: 1224 passed, mypy --strict clean on 26 files,
ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:30:32 +02:00
4ce14ae5dd feat(cli): okf build, one installed command for folder in, bundle out
Until now "run the door over a folder" was a shell loop over two scripts
under `tools/`, with nine flags between them and a `--path-prefix` rule
that lived in a code block in a measurement report. Neither script was
packaged (`pyproject.toml` ships `src/llm_ingestion_okf` only), so the
path the published K1/K2 numbers were measured on was reachable from a
clone and nowhere else.

`okf build <folder> --bundle <dir>` is that path, packaged, declared as a
console script and installed with the wheel. It is orchestration only:
the proposer and the corpus harness MOVED into the package
(`llm_ingestion_okf.propose`, `llm_ingestion_okf.corpus`) and the two
`tools/` scripts became thin entry points to them, so the published
reproduction blocks still run and there is exactly one implementation of
each rule. Neither move adds a dependency or a model call.

Two decisions belong to this layer and are stated where they are made.
A document's proposed paths are scoped by its RELATIVE PATH minus the
extension, not its basename: the door walks recursively now, and two
documents named alike in different folders would otherwise collide on a
path Door B is supposed to make impossible rather than merely detect.
And omitted timestamps do not come from the clock -- `--ingested-at` and
`--proposed-at` default to one shared epoch constant, because a
wall-clock default would put a changing byte in the artifact and take
rebuild-equals-incremental away from every caller who did not pass them.

Arm C and Arm D stay off and are not exposed here.

Measured on the 43-file K2 corpus, one invocation against the two-script
bundle of 2026-09-03: N = 43 computed, merged 39/43, coded rejections
4/43 (`extractor_unknown` 3, `extractor_empty_pdf` 1), K1b 39 + 4 = 43,
exit 0, 779.43 s. 1107 of 1108 files byte-identical. The one that
differs is the root `index.md`, by exactly the `log.md` link a commit
fifteen hours younger than the stored artifact adds -- appending that
line to the stored file reproduces the new one byte for byte. Against
the two scripts at THIS commit the trees agree in full, which is what
the byte-identity test holds.

Suite 1127 passed after `git add` (1113 before), mypy --strict clean,
ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 05:06:33 +02:00
aa87eb8818 feat(inbox): walk the drop directory recursively
Door B listed `inbox.iterdir()` and kept only top-level files. A file in a
subdirectory was neither ingested nor refused: it appeared in none of the
result's buckets, so a nested drop produced a bundle that was silently short
of what was dropped and no count said so. That broke the K1b identity for any
inbox with folders in it. Operator decision 2026-09-06.

- `walk_inbox` is the ONE walk rule, shared with `tools/okf_corpus_run.py`:
  the denominator N is now counted over exactly the set of files the door
  ingests, rather than over a second listing that happened to agree.
- Sorted on the whole relative path, not the basename, so the order is a
  function of the tree; that is what keeps rebuild-from-scratch byte-equal to
  an incremental update.
- A concept's `source_file` is the path relative to the inbox root,
  `/`-separated. The concept NAME still comes from the basename, so two
  folders holding one basename hit the existing §3 collision refusal instead
  of one silently claiming the other's concept.
- Dot-directories and a bundle directory inside the inbox are skipped with a
  CODE, in a new `InboxResult.skipped`. Recursion makes the door's own output
  reachable as its own input; a silent skip would be the same
  absence-without-a-denominator defect one level down.
- `--path-prefix` reduces per component and rejoins with `/`, so the caller
  driving a nested corpus can carry the relative directory. Reducing the whole
  string folded the separator into a `-` and flattened `sub/sub2`.

`tests/test_inbox_flow.py::test_subdirectories_are_not_walked` asserted the
opposite and is superseded in place, with the reason written down.

Measured on the K2 corpus (flat, N=43): 39/43 merged, 4 coded, K1b holds. The
bundle digest is
`1472e98aec8643c5beee540f4c42b5e437bd26e7c61d69a91bcff799f06a6d13` over 1108
files -- byte-identical to a run of the same corpus at 190086f WITHOUT this
change (`diff -r` exit 0), so recursion costs a flat inbox nothing. It differs
from the stored 2026-09-03 artifact by one line in `index.md`
(`- [Corpus run history](log.md)`), which 95eb271 added 15 hours after that
bundle was built.

Suite 1113 passed, `ruff` clean, `mypy --strict src/ tools/` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 04:11:00 +02:00
0170c526ad docs(extract): the extra now ships an office converter
The README told consumers that `docx` and `xlsx` ship no parser and always fail
fast. True when written; false the moment the converter seam landed -- and
false SILENTLY, because prose has no test. This repository has been bitten by
that exact shape before: a published guarantee is a test obligation.

So the correction comes with `tests/test_docs_promises.py`, which compares the
README's declared format list against the registries it describes and fails on
a format added without touching the README, on the old claim reappearing in any
wording, on an unmeasured row going unnamed, and on the exclusions being
dropped. Negative control: removing one format from the README's marker turns
it red.

The README now states which rows are measured and which are not. Three of the
five office rows have denominator ZERO in the corpus -- they work by
construction and have never met a document anyone wrote. They are not known to
be broken and not known to be right, and a reader should not have to open the
source to learn which.

The CHANGELOG's shipped entry is left as written, because a changelog records
what a release did; the correction is stated at that line instead so a reader
arriving there is not misled.

Suite 913 -> 917.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:15:55 +02:00
cf65635648 docs(plan): record the structure-derivation decisions, mutations and cost
Six decisions, each with the failure it prevents: confidence carried in the
format, certainty stated per field, supersession never inferred from one
document, the index as a projection rather than an append, facets as a new
profile, and Door B's ownership stamp left deliberately open.

Six findings reported as findings rather than as gaps, per the order: the cost
of closing the metadata gap is 3.3x-6.4x the flat index and whether that keeps
the OKF arm's cost advantage cannot be answered from this repo -- it needs
their bake-off re-run on their corpus; a reference to a parent-level number
does not resolve to a child, and the fix needs a real corpus before 'unique'
can be defended; OKF_V0_2's verified/status/stale_after are still expressible
and unwritten for the reason already recorded.

Every number carries its denominator, and the supersedes 0/55 carries a
known-positive control (50/55 on a corpus that has shared numbers) proving the
query can find what it reported missing.
2026-08-27 00:39:48 +02:00
6b2a4d3bc3 chore: bump classifier to Alpha, rename README scope heading
Reflects actual maturity (615 passing tests, real consumer): PyPI
classifier "2 - Pre-Alpha" -> "3 - Alpha". README heading "Planned
scope (v1)" -> "Implemented scope (v1)" since all three doors (A/B/C)
are implemented. Cosmetic only, no functional change. Operator
decision recorded in docs/2026-08-24-beslutninger-classifier-a5-f7.local.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGwbxHqib3QcaFa7hhMpB7
2026-08-25 06:33:39 +02:00
e60f7261a1 build(deps): move the guard pin from >=0.3,<0.4 to >=1.2,<2.0
The operator's condition for revisiting the pin was met (v1.2.0 contains
the flow-mapping frontmatter fix, commit 5870483) and the operator has now
approved the move itself, dispatched as its own order because it changes
consumer-visible Requires-Dist and shifts golden-fixture admission.

Floor 1.2: this library needs the flow-mapping support (`generated: { by:
x, at: y }`) that landed there — without it Door C fail-secures every
concept carrying that stamp. Ceiling <2.0, not narrower: the guard's own
1.0.0 release freezes its exported surface until a 2.0.0, and explicitly
keeps calibration (severities, dispositions) free to move within 1.x, so a
tighter ceiling here would claim a stability guarantee neither side needs.

Re-measured through Door C against the guard's own default
(allow_reserved=True, matching how the earlier recommendation measured
it), over the 9 concept documents across all four Door-A goldens:
4/9 admitted at 0.3.4 -> 8/9 admitted at 1.2.0, confirming the number
already reported. The ninth (a `sources` block-list carrying `resource`)
stays refused by design (G30) and is not expected to move.

Failing-test-first: test_guard_adapter.py::test_guard_version_is_inside_the_pin
and test_packaging.py::test_the_only_runtime_dependency_is_the_security_boundary
were updated to the new pin first and confirmed red against the
still-installed 0.3.4, then `uv sync --extra extract` installed 1.2.0 and
both went green. Full suite (615), ruff and mypy --strict clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxcxzNwpX1kDP53n1rLhM5
2026-08-23 21:05:18 +02:00
fb9812fbe7 docs(install): measure the uv install channel and correct the per-tree wheel range
The comment on [tool.uv.sources] claimed the built wheel carries
`Requires-Dist: llm-ingestion-guard<0.3,>=0.2`. That is the `v0.4.0` tag's
range, not this tree's, and it had been stale since the pin moved. A wheel
built from this tree carries `<0.4,>=0.3`, measured against the built wheel.
The old value is kept and attributed to the tag it belongs to rather than
substituted, because it is still true there.

Five measurements were run before editing, on uv 0.9.8 with an empty cache,
because the plan of record was to REMOVE this entry and the README claim it
supports had never been measured in more than one form:

- uv, direct: the README one-command install resolves the guard from the
  tag's [tool.uv.sources]. Third independent confirmation (07-25, 08-20,
  08-21).
- uv, transitive: a separate consumer project naming only this package still
  resolves the guard from the entry, because this package reaches it as a git
  source. Not previously measured.
- pip, negative: installing this package alone fails with exactly the error
  the README names, and the message prints the tag's own range.
- pip, positive: the README's two commands in order install clean and import.
- core install: brings the guard and no binary parser packages.

The entry is therefore load-bearing, not scaffolding: a wheel carries
Requires-Dist and nothing else, so it cannot survive an index install, and
while the guard is off-index removing it would break the documented uv path.
No package index carries the guard today, which was the premise removal
depended on.

The README install block measured correct as published and is unchanged. Its
test count had drifted: 596 with the [extract] extra, 589 passed and 7 skipped
without, both measured today.

Wheel metadata is byte-identical before and after, so the change is inert.
2026-08-21 21:10:28 +02:00
658b7aafe0 feat(extract): implement pdf behind the [extract] extra with pdfplumber
Order G2a. Populates the optional `[extract]` extra for the first time with
one parser, `pdfplumber>=0.11.10,<0.12` (MIT), and wires `pdf` through it.
The default install is untouched: exactly one runtime dependency, stdlib
otherwise, enforced by test_packaging.py.

The gate for `pdf` becomes an import probe rather than a frozenset membership
test, exactly as extract.py's docstring had promised. The rejection does not
change: without the extra, `pdf` still raises `extractor_extra_missing` with
the same message. That behaviour is asserted UNCONDITIONALLY via a sys.modules
monkeypatch, so it holds on machines where the parser is installed too — a
skip would have preserved nothing there. Verified in a clean venv without the
extra: 589 passed, 7 skipped; with it, 596 passed.

`docx`/`xlsx` are unchanged and still fail fast — the extra names exactly what
it ships.

The parser choice was forced by measurement, not preference (b73dd9d,
docs/2026-08-21-g2-pdf-extraction-measurement.md): on a real requirement table
pdfplumber keeps 4 of 4 rows with label and value on one line, where pypdf,
pdfminer.six and pymupdf each keep 0 of 4. pymupdf is additionally out on
licence (AGPL-3.0), which an MIT package must not push onto a consumer.

Three facts from that measurement are now carried in code rather than in a
report:

- Extracted text is pinned to an exact transitive parser version
  (pdfplumber pins pdfminer.six==20260107; date-stamped, no stability
  contract). tests/test_extract.py freezes the expected text of a committed
  hand-written fixture so a parser upgrade breaks something visible instead of
  drifting silently. Reasoning at the declaration site and in
  tests/fixtures/README.md.
- Determinism within a version is now held by a test, not only measured once.
- Drawn content does not survive extraction. Every pdf extraction emits the
  new `ExtractionWarning`: figures have no text to recover, so a bundle built
  from drawn documents is incomplete by construction. Stated categorically
  rather than detected — deciding "is there a figure here" is the layout
  heuristic G2b declined.

Two new error codes, both mirroring existing patterns: `extractor_empty_pdf`
(a scanned/image-only PDF, refused rather than persisted as an empty concept)
and `extractor_pdf_error` (parser failure wrapped, never leaked).

Structured table recovery (G2b) is NOT implemented and is documented as out of
scope: two independent parsers return the same wrong shape, so the breakage is
document geometry, not a library choice. PDFs enter as prose.

Also corrects an install promise this change would otherwise have published:
the README no longer presents a bare `pip install 'llm-ingestion-okf[extract]'`
as working, because the package is not on an index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtNhsdHnMGtMi7U2mvMU8z
2026-08-21 20:22:39 +02:00
f01c04cd92 docs(readme): make the one-command uv path the first copyable block
The one-command uv install existed only as prose with a Unicode ellipsis
in place of the URL, so it could not be copied. The two-command pip path
was first and copyable, but the second command alone fails
(No matching distribution found for llm-ingestion-guard) since pip does
not resolve the transitive git dependency uv reads from
[tool.uv.sources]. Re-measured 2026-08-20 with an empty uv cache: the
one-command path still installs guard==0.2.0 + okf==0.4.0 cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mVhrxD21mPtaLMrGdixUq
2026-08-21 11:26:55 +02:00
bd522a1f61 docs(readme): show the test command, and stop calling a shipped phase planned
Two defects on the published surface, both measured this session rather than
assumed.

org-ops' census 07 found no test command inside a fenced block in our README,
and the finding is true: the string "pytest" did not appear in the file at all.
A stranger could install the library but could not confirm it works. The block
now names the command, and says the thing the command alone would not: it needs
a clone, because the wheel packages only src/llm_ingestion_okf and tests/ lives
at the repository root. The 589-test count and the run date are measured, not
carried over.

Running org-ops' own A5 criterion ("no claim on the surface is untrue") against
ourselves found the second one. The status paragraph said phases 1 and 2 are
implemented and phases 3-4 are planned, while profiles.py:701 has shipped
STRICT_V1 since phase 3 landed. The Roadmap section and the profile table were
already consistent with phase 3 being done, so the stale claim sat alone in the
one paragraph a reader meets first.

Fixing only the first would have repeated the most expensive finding we have
recorded: writing a trap down is not running it.

Verified with the instrument org-ops supplied, against this branch:
  awk '/^```/{f=!f; next} f' README.md \
    | grep -icE 'npm (run )?test|node --test|pytest|vitest|jest|make test|bats'
  -> 1 (was 0)

Docs only. No release, no tag, no pin movement: the guard pin and the paired
install tags are untouched, and the open release decision stays open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgzRG5EGPk72Mj4JN4uhQS
2026-08-13 21:09:16 +02:00
200ed61701 docs(readme): pair the guard tag to the okf tag, not to this branch
The public install block did not work. It paired guard `@v0.3.4` with okf
`@v0.4.0`, and `v0.4.0` declares `llm-ingestion-guard>=0.2,<0.3` — which
v0.3.4 does not satisfy. With the guard absent from any package index, pip
then fails with `No matching distribution found for llm-ingestion-guard`:
exactly the error the two-step block exists to prevent. Anyone following the
front page hit it.

Three instances of one conflation, all measured, all introduced by writing
this branch's pin into a sentence about a tag:

  1. l.23 guard tag — repaired to `@v0.2.0`, which is what `v0.4.0` needs and
     what `v0.4.0`'s own README already says.
  2. The uv sentence claimed uv reads `[tool.uv.sources]` in "this project's"
     pyproject; installing `@v0.4.0` makes uv read the *tag's* file. Measured:
     that entry in `v0.4.0` points at guard tag `v0.2.0`, so the uv path was
     correct and only described wrong.
  3. Requirements cited `Requires-Dist: llm-ingestion-guard<0.3,>=0.2` as
     verified against the wheel, under a stated range of `>=0.3,<0.4`. A wheel
     built from this branch today carries `<0.4,>=0.3`; the quoted evidence was
     a true measurement of the old pin left standing under the new one.

The 2026-07-25 measurement stamp is kept rather than dropped: `v0.5.0a2`'s
frozen README shows that measurement was of the guard `v0.2.0` + okf `v0.4.0`
pair, which is the pair restored here. `b9ba42d` moved the numbers away from
what had been measured while keeping the date that vouched for them.

Docs only, by operator decision. Making the block's numbers true by releasing a
stable tag that carries this branch's pin is the other repair, and it is a
release: it belongs to the open release decision, and not inside the pilot
freeze.

The trap was already written down ("the install channel depends on the tag, not
`main`") and the public README carried the defect anyway. Recording a trap is
not applying it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PoZ2ZCWdkkZ3QYMHJFRX4z
2026-08-10 21:15:41 +02:00
8318605e34 feat(profiles): DEFAULT stamps commons' O2 generated, V1 executed
Commons ratified V1 2026-08-02 and executed it at `54e0ec7`; verified
against their tree rather than taken on report. ingest-spec.md:217 now
defines `generated` as `{ by: process:okf-ingest, at: <ingested_at> }`,
unquoted, `at` repeating `ingested_at` verbatim. `generated: true` no
longer appears in the spec.

`DEFAULT` states commons' §5 layer, so its stamp is theirs to decide.
`DEFAULT.ownership` gains the actor; the four goldens this repo's plan
named in advance were regenerated by RUNNING the materializer, each on
its own case's `ingested-at.txt`. The v0.2 golden was untouched, as
predicted -- it has carried the O2 form since D5.

Not a migration onto OKF v0.2: `DEFAULT` stays v0.1 on every axis
upstream owns and still emits no `sources`. Commons' spec and the Google
version are independent axes, and comments that narrated them as one
were rewritten rather than left to mislead. README and CLAUDE.md said
the additive rule without that boundary, which would have told a
consumer their DEFAULT bytes can never move; both now state it.

V-A3 is amended, not dropped. `DEFAULT` must OWN the mapping it now
writes -- a profile refusing its own output fires the collision gate on
files its own previous run wrote -- while a mapping naming a foreign
actor, or §7's `human:` actor on curated content, stays unowned. That
half is what carried the safety and it is asserted directly.

§11's stamp-integrity condition moved with the value: the forgeable
stamp was `true` and is now the mapping naming the ingest actor. The
defence was never the value -- the §3 scan globs `ingest-*.md`, so a
Door C import is unreachable however well it forges. Second spoof test
added; both were hand-mutated (glob widened to `*.md`) to confirm they
can fail.

The characterization test derived its foreign-stamp fixture from the
literal `generated: true`, which V1 leaves without a referent -- a
silent no-op waiting to happen. It now derives the needle from the
profile and asserts the substitution occurred.

Door B is deliberately untouched: not the ingest-spec's, marker is
`generated` + `source_file`, disjoint from Door A's `ingest_manifest`,
and the divergence predates V1.

Nothing released or notified. The pilot set pins `v0.5.0a2`, not `main`,
so this is invisible to portfolio-optimiser's freeze and demo; the
consumer exposure report is owed at the release that carries this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwcjUXbKySLbEG5WqTNkta
2026-08-09 12:29:05 +02:00
b9ba42da6f docs(readme): give install its own heading and match the forge description
The repo-standard gate (v0.1.1, class `standalone`) flagged two ERRORs on the
first screen. Both are about a reader who arrives from outside:

- The opening line now repeats the forge description verbatim. That is the
  only place where description, catalog and README can be machine-checked to
  agree; a paraphrase reads fine and is unverifiable.
- `## Install` exists as a heading, on the first screen, with the commands
  under it. They were previously buried in `## Requirements` two screens
  down, which is where a human eventually finds them and an agent does not.
  `## Requirements` keeps the dependency policy and the direct-reference
  rationale, and points at `## Install` for the commands.

The Install block also states which tag a stranger should pin: `v0.4.0` is
stable, `v0.5.0a2` is the OKF v0.2 pilot pre-release and is not for general
use. Nothing else moved; the body below the first screen is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJUFZhHzf9AqLucMsJy6u1
2026-08-03 21:54:03 +02:00
f536e1384d feat(guard): bump the pin to >=0.3,<0.4 and pin Door C's allow_reserved=False
Measure first, widen after. The 19-fixture guard-surface suite was re-run
against v0.3.4 in a scratch venv before the range moved, and reproduced the
three deltas measured against v0.3.3 exactly, with none added. v0.3.4 is the
tag pinned rather than v0.3.3 because it shipped first and repairs a quadratic
regex (okf._MD_LINK_RE) that sits on Door C's own call path.

Door C now passes allow_reserved=False explicitly. The guard added the keyword
in the 0.3 line and defaults it True for received bundles, which would merge a
sender's index.md / log.md instead of rejecting them. The override keeps the
unconditional reserved-name refusal committed to before the keyword existed,
and the reason is structural rather than a second opinion on the guard's scan:
Door C generates the merged bundle's index.md from what it merged and writes
every merged concept verbatim, so a sender's index.md would be a second and
irreconcilable claim on one path.

This is not a behaviour change for anyone on the previous pin: under v0.2.0
the keyword did not exist and reserved names were refused by construction.

The floor is >=0.3 and not >=0.2 for a measured reason. allow_reserved is
absent in v0.2.0 and present from v0.3.0 onward, checked across all five tags:
a >=0.2 floor would admit a version that raises TypeError on every Door C
import. That measurement also corrects a recorded premise -- the plan said the
keyword "shipped in v0.3.3", which read the first version we ran the suite
against as the version it was introduced in. The conclusion held; the reason
did not, and the reason is what a future bump would have relied on.

test_door_c_pins_allow_reserved_false_against_the_guards_default locks both
halves: that the guard still defaults True, without which the override is a
no-op that would pass forever over nothing, and that Door C overrides it.

586 tests, mypy --strict clean, goldens byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V2v1hrDhrff2H3y2TNJHkF
2026-08-02 21:08:53 +02:00
c6d64c3fd6 feat(okf-v0.2): D4 — door C surfaces the §10 pointers it imports [skip-docs]
V6, settled by the operator today: import and report, not refuse.

Door C imports the POINTER to executable code and never the code — it
writes concepts verbatim and skips every non-`.md` file. So an imported
Attested Computation can name an `executor`/`attester` resource that did
not arrive, or one that RESOLVES against a file the destination tree
already holds under that path. The second is the outcome worth
surfacing: it looks valid.

Refusing was the plan's leaning and is not what shipped. §14 forbids a
consumer to reject a bundle over a broken cross-link and does not settle
whether `executor.resource` is one; §10.5 asks a consumer to surface
rather than silently drop. Reporting honours the second without testing
the first, and leaves the door's one invariant — verbatim bytes — alone.

`ImportResult.unverified_references` is an advisory over the merged set,
not a fifth bucket: every concept it names has already merged, the
bytes are unchanged, and a refused concept is never named (there is no
imported pointer to check).

The report is at KEY level, and that is a measured limit rather than a
choice. Resolving the resource means reading `executor.resource` — the
value the line-oriented parser cannot recover in either canonical form:
a block mapping flattens and collides, a flow mapping stays one opaque
string. A resource-level report would be empty or wrong on exactly the
forms upstream writes. Precision arrives with the structured reader
(D1b); the key-level signal is robust in both forms today.

[skip-docs] is on the CLAUDE.md half only: README carries the new
public surface (`unverified_references`), and the invariant this work
put in CLAUDE.md — flow form, never block — landed with the previous
commit and needs no restatement.

584 tests, mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKKMwi7e7PVHoFW6dJK5XP
2026-07-31 21:44:48 +02:00
deeb248091 feat(okf-v0.2): D4 — the Attested Computation contract, format only
Upstream §10 adds a concept type carrying a sanctioned way to compute a
value. This lands its FORMAT: the five contract fields for emission and
round-trip, and §10.2's one requirement. No execution — upstream defers
the receipt and verdict wire formats, so there is nothing to build a
runtime against.

Two additions, both additive:

- The five fields (`runtime`, `parameters`, `computation`, `executor`,
  `attester`) join `OKF_V0_2`'s emission order as one block, internally
  in §10.2's own listing order. Without it they still emit — in `emit`'s
  sorted tail, where `attester` precedes `runtime`, alphabetical order
  standing in for the contract's own. No bundle that carries none of the
  keys changes by a byte, and the v0.1 profiles gain nothing.

- `FrontmatterSchema.required_by_type` expresses "`runtime` is REQUIRED
  for this type and no other" — the first rule here keyed off a
  frontmatter VALUE rather than a key. It cannot be `required`, which
  would demand `runtime` of every document. A type the mapping does not
  name carries no extra requirement, which is what keeps it inside §14:
  a consumer must not reject on an unknown `type`, so a conditional
  keyed on an unknown type stays silent rather than guesses.

Also pinned, measured today: the line-oriented parser cannot read §10's
canonical BLOCK form. `executor` and `attester` both carry a `resource`,
and with no indentation model the second overwrites the first —
`executor.resource` is lost silently, no error. Characterized rather
than fixed: reading that form needs the structured reader (D1b), and a
half-reader that drops half a contract is worse than one that never
claimed to read it. CLAUDE.md gains the invariant that falls out of it:
we emit flow form, never block, or we write bundles we cannot read back.

578 tests, mypy --strict clean, goldens byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKKMwi7e7PVHoFW6dJK5XP
2026-07-31 21:35:23 +02:00
2504011010 feat(okf-v0.2): D5 — the v0.2 golden fixture, with okf_version in root frontmatter
Placement settled by catalog's own reading of upstream at the pinned commit
3fcbb9f: SS8:509-510 and SS12:773-775 both put `okf_version` in a bundle-root
`index.md` frontmatter block, and SS12 calls it the only place frontmatter is
permitted in an index. Catalog's spec says the opposite about the same file;
that divergence is theirs against upstream, and we conform to upstream.

The value never touches a profile. `OKF_V0_2.index.root_frontmatter` names the
key; the caller supplies the value through a new keyword-only
`root_frontmatter_values` mapping. That keeps V4/V-A5 intact - `okf_version`'s
value tracks the upstream Google version and belongs to catalog (E1), so a
constant here would claim a decision we do not own and would have to be chased
on every upstream release. In the fixture the value is fixture DATA
(`okf-version.txt`), not a literal in our source.

Ordering comes from the policy, not the caller's mapping: a dict preserves
insertion order, so two callers passing the same keys would otherwise emit
different bytes. A key the policy does not name is refused fail-fast, before
any disk mutation. Omitting the argument emits no block at all - SS12 is a MAY
and none of upstream's four reference bundles declares the key.

The block is written only when the index is CREATED, so a re-run into an
existing bundle stays byte-identical (A-E5).

Raw-byte assertions rather than parsed ones, on the committed fixture as well
as on fresh runs: catalog measured that a quoted value fails their shape regex
with exit 1 and that a BOM hides the marker while still exiting 0.
`yaml.safe_load` returns "0.2" either way and strips a BOM first, so a parsed
assertion masks exactly those two defects. Asserting the frozen fixture catches
what a self-comparison cannot - regenerating from a broken emitter moves both
sides together.

A-E6 is now placement-explicit (promised catalog in 99cf987), and separates the
two byte properties: BOM-free is a property of the file, unquoted is a property
of CATALOG'S GATE and not of OKF v0.2 - upstream's own SS12 example is quoted,
so their gate rejects the spec's canonical form.

README gains the upstream-version section it was missing; CLAUDE.md gains the
mechanism behind "no profile hard-codes an upstream version": a profile names a
key, a caller owns its value.

550 -> 559 tests. test_profile_threading's `OKF_V0_2.index is DEFAULT.index`
assertion is replaced rather than deleted: object identity was a proxy for "the
shipped profiles differ in no NAME-bearing field", which is what makes the
synthetic test profile necessary, so the guard now asserts that directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dgkSPjkLpACjMayd9R5jx
2026-07-31 17:27:48 +02:00
9436427520 feat(phase-3): the index policy becomes configurable, with the reader that judges it
`IndexPolicy` grew five judging fields and `IndexPolicy.violations`, closing the
gap a test has been pinning since `848e395`. `STRICT_V1.index` is now the proving
consumer's shape rather than DEFAULT's.

The design was settled by a conflict rather than by a preference. The convention
owner reported that an index is an authored count of a directory's children and
that a validator enumerating the directory has implemented the wrong contract.
Checked against the proving consumer before adopting it, the two turn out to be
directly opposed: gate BUNDLE_INDEX_COMPLETE (validate.py:1081-1120, ERROR) builds
its expected set by enumerating the directory and demands an exact bidirectional
match, and bundle.py:498-567 writes every index from a tree walk. Neither is
incoherent inside its own spec, so authored-versus-derived is a policy field in
both directions and a library invariant in neither.

Consequences encoded rather than documented: nothing here enumerates a directory
— the caller supplies the listing, `violations` refuses one when the profile's
index is authored and refuses to run without one when it is derived, so code
written to the wrong contract fails at the call instead of passing every test one
would think to write. Root and nested indexes are asymmetric (confirmed in both
consumers, different key sets). A per-entry description is template-level, so
`render_link` refuses both a missing description and an unwanted one.

DEFAULT keeps upstream's root-only index and judges nothing, for the same measured
reason it carries no required frontmatter key set: upstream binds `index.md` to
the bundle root alone, so a judging default would condemn conforming bundles.

25 new tests, 493 green. C1 re-proven: `git diff --stat examples/` empty.
2026-07-25 20:30:54 +02:00
4ea00a902f docs(install): correct how the guard reaches a consumer, measured both ways
The v0.4.0 release commit claimed the [tool.uv.sources] entry "never
reaches consumers". Measured against the pushed tag in clean
environments, that is wrong in one direction and understated in the
other:

- uv, installing this package from git, DOES pick the guard up from the
  tag automatically. uv reads pyproject.toml when it builds from the
  source tree, so the source applies to the consumer too, not just to
  development here.
- pip does not read it, and fails outright: "No matching distribution
  found for llm-ingestion-guard<0.3,>=0.2". The guard must be installed
  from its own tag FIRST. Verified that the guard-first order then
  installs cleanly and that the shipped adapter runs against the real
  guard in that environment.

What did hold is the part that matters for the pin: the built wheel
carries `Requires-Dist: llm-ingestion-guard<0.3,>=0.2`, so the range is
still the declared dependency and the git URL is still a channel.

README now states the pip order as the default instruction, with the uv
shortcut beside it, because a consumer hitting the resolution error would
otherwise read it as a broken release.
2026-07-25 12:24:54 +02:00
524ade78a6 chore(release): v0.4.0
Phase 2 ships: Doors B and C, the extraction registry, the guard adapter,
and with them the first and only runtime dependency this library takes.
The minor bump is the news consumers are waiting on -- installing the
package now installs llm-ingestion-guard>=0.2,<0.3, though importing it
still does not import the guard.

Two behaviour changes carried in the same release are called out in the
changelog rather than buried: an extraction title containing `[` or `]`
now fails at manifest load (ingest-spec section 4), and the section 3
ownership scan no longer deletes files another manifest stamped into the
same bundle.

uv.lock was stale -- it predated the guard dependency and could not be
regenerated, because the guard is not on a package index. A
[tool.uv.sources] entry pins the same tag the README documents, so
`uv lock`/`uv sync` resolve. It is uv-specific dev metadata: the built
wheel still carries `Requires-Dist: llm-ingestion-guard<0.3,>=0.2`,
verified against the wheel, so the range remains the pin and the git URL
remains a channel.

Binary extraction (pdf/docx/xlsx behind [extract]) is deliberately not in
this release and is stated as outstanding in both README and changelog.

425 tests green; ruff, ruff format and mypy --strict clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2aKJxLejT9S8jYwoZ9fut
2026-07-25 12:08:48 +02:00
241e00f27a feat(guard): wire Doors B and C to the real guard (Phase 2 step 4)
Adds llm_ingestion_guard>=0.2,<0.3 as this library's first and only runtime
dependency, and guard_adapter.py -- the one module that imports it. The flows
themselves are unchanged: they still take an injected gate, and importing the
package still does not import the guard, so a Door A consumer is unaffected by
the dependency's state.

Door B screens the exact bytes it persists. The guard's §6 bookends
(prepare_input -> model -> screen_output) assume a model call in between; this
library makes none, and prepare_input returns prompt-shaped text (sanitized
AND spotlight-fenced with a per-call nonce) that must never reach disk. So the
adapter calls screen_output alone, on the extracted text as it stands, and
hands that same text back -- the verdict is then a statement about the bytes
actually written. This supersedes the plan's "bookends" wording, recorded
there under "Settled during implementation (step 4)".

It follows that the gate refuses rather than repairs: a file carrying an
invisible carrier is rejected, not stripped and persisted. Sanitizing first
would write a document differing invisibly from the operator's file while
source_sha256 still points at the original bytes. Operator decision; the
policy is PRESET_USER_UPLOAD, so any finding at all is held back.

Door C hands the bundle over whole to okf.import_bundle, which resolves the
cross-link graph across concepts. Per-concept reasons are derived from the
scan findings (severity:label) because stamp_concept keeps the disposition and
drops the reason strings behind it.

Assumption B1 closes as a signature smoke test over what the adapters actually
call -- screen_output and okf.import_bundle signatures, the Disposition values
both doors compare by value, the Origin/Channel vocabularies Door C validates,
the result fields read, and the upload preset's shape. prepare_input is not
pinned: drift there cannot reach this library. Behaviour is pinned against the
real scanner too, including the persist-gate proof that a fail-secure fixture
leaves the bundle byte-identical.

B2 closes with it: git+https tag install over anonymously readable HTTPS, no
credential. A direct reference is an install-time channel, not the pin -- the
range stays in pyproject, is satisfied by the tag install today, and resolves
normally once the package index exists. A packaging test enforces that the
guard remains the only runtime dependency (verified by hand-mutation).
2026-07-25 07:32:45 +02:00
f10fc60de2 feat(import): Door C flow against an injected import gate (Phase 2 step 5)
Reads an external OKF bundle as {bundle-relative path -> document text},
hands it WHOLE to an injected gate over the guard's okf.import_bundle (a
bundle-level call: it resolves the cross-link graph across concepts), and
merges only concepts clearing the non-blocking floor. Same injection pattern
as Door B, so the core stays dependency-free while the CI channel for the
real guard is settled.

Two constraints shaped the design and are pinned by tests:

- A merged concept is written VERBATIM. Stamping provenance into it would
  require round-tripping its frontmatter through this library's line-oriented
  parser, which cannot represent the block lists the guard's parser accepts --
  silent data loss -- and would persist bytes the guard never screened.
- Ownership is therefore proven by content identity: identical bytes at the
  target name are a no-op re-merge (re-import of an unchanged bundle is
  idempotent), and anything else at the name is refused. Curated content and
  an updated concept are refused alike; refusing is what never destroys.

The floor is fail-closed beyond the plan's "no error" wording: an error, an
unrecognised disposition, and a concept the gate returned no verdict for are
all refusals. quarantine_review stays its own bucket, as at Door B.
origin/channel are validated against the guard's pinned vocabulary -- it
derives trust from origin by enum identity, so an unrecognised string would be
silently downgraded rather than caught.

Three primitives promoted for reuse rather than duplicated:
reduce_to_id_grammar and check_filename_length to materialize.py, and
extract.decode_text. Door C slugs the WHOLE concept path, so tables/users.md
and views/users.md stay distinct. Concept discovery folds case explicitly
rather than globbing *.md, whose case-sensitivity follows the filesystem and
would import the same bundle differently on APFS and ext4.

README's "what is gated today" section corrected: it claimed nothing is gated,
which is no longer true, but the honest statement is narrower than "the doors
are gated" -- the library cannot verify that an injected adapter is a real
guard, and a permissive stub is believed.

405 tests green; ruff, ruff format and mypy --strict clean.
2026-07-25 06:57:25 +02:00
7fe073ac61 docs(security): state plainly that Door A is ungated
The module docstring and README claimed the library "calls the guard at
every persist gate". That described the intended end state in the present
tense. Door A -- the only door shipped -- has zero runtime dependencies and
calls no guard function before writing to disk.

Both places now say so, and state that gating external or untrusted content
is the caller's responsibility (okf.import_bundle, or prepare_input /
screen_output) until the persist gates land with Doors B and C.

Reported as F2 in a dogfood review by claude-playlist-corpus, which read the
earlier wording as safe-by-default and had to wire the gating itself.

No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdVgowYC4LARgvNdNMiuvz
2026-07-19 10:09:25 +02:00
dae0bd1a28 docs(readme): update status — phase 1 implemented 2026-07-16 20:14:35 +02:00
ec2d632b4f docs(readme): refresh status and link the phase plans
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
2026-07-16 11:01:09 +02:00
e57afecf72 docs(scope): expand roadmap to cover all eight OKF surfaces
Operator decision: the initial v1 boundary (portfolio pair only) left
five of eight OKF surfaces uncovered. CLAUDE.md/README now define a
four-phase roadmap: door A, doors B/C, configurable bundle contract
(wiki strict-v1), and a zero-dep Node/ESM half for the second-brain
plugin world. Halves share contract and fixtures, never code. Security
stays with llm-ingestion-guard in both runtimes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
2026-07-16 10:32:58 +02:00
47df0aeb4b feat: initial commit — repo scaffold and v1 scope
Scope settled 2026-07-16: implements portfolio-optimiser-commons
ingest-spec (commons keeps spec authorship), Python 3.10+ stdlib-only
core, security delegated to llm-ingestion-guard at persist gates.
Doors: spec-based ingestion, bundle inbox (md/txt/csv/json/html core,
binary formats behind [extract]), external bundle import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
2026-07-16 10:12:59 +02:00