Commit graph

159 commits

Author SHA1 Message Date
ddd6338f02 feat(ingest): add the MCP connector as a transport inside the http family (S2.2)
Commons settled on 2026-08-01 that MCP is an extension of the `http` source
family, not a fourth family (`shared/ingest-spec.md` §4). This implements it
with ZERO schema change and zero spec amendment.

The transport discriminator lives in `base_url`, not in new manifest fields:
the shared library rejects unknown manifest keys fail-fast, and we consume it
pull-only at a pinned v0.3.1, so `server_ref`/`tool` as fields would have meant
a spec amendment plus a library release. It buys nothing — the library already
joins `base_url` + `/` + `query`, so `mcp+stdio://<server_ref>` + `<tool>`
reproduces exactly the two-part structure the (now stale) reference plan wanted.

Staying inside the family INHERITS what a fourth family would have had to write
and could have forgotten: the §8 network grant (measured to fire before any tool
call), the `max_rows` cap, §5 verbatim fenced rendering, and the §7 provenance
stamp. The discriminator gates rather than labels — `mcp_get` refuses a URL it
does not own, so an MCP transport can never quietly serve an `https://` manifest
and leave the bundle's provenance claiming a transport that was never used.

Parsing is string-based, not `urlsplit`-based: `urlsplit().hostname` lowercases
the host, which would silently break the case-sensitive env lookup `server_ref`
depends on.

`ingest.py` is untouched — it is AST-guarded mcp-free, so the transport lives in
its own module and is opt-in at the call site. `ingest_mcp.py` imports the open
`mcp` protocol client but never `agent_framework`, keeping the seam D7-portable.

Load-bearing, six mutations all measured RED: detach the scheme guard · make the
refusal unconditional · swap parsing to `urlsplit().hostname` · skip non-text
content instead of raising · force `allow_network=True` · smuggle in a MAF
import. Both source files restored byte-identical (`shasum -c`) after each.

Honesty: `stdio_call_tool` (the real stdio path) is written but never executed
end to end — every test injects a canned tool call, so the suite spawns no
subprocess and opens no socket. No golden fixture, and MCP stays unwired in the
optimiser run path. Stated in docs/extending.md rather than implied away.

555 -> 578 tests; ruff + mypy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112FPR5TX6pDLiNicBPzE8i
2026-08-02 21:14:57 +02:00
8e9f385d17 test(spec): make the pulled method spec load-bearing at rule level, not just section level
The spec arrives by subtree pull from commons, so drift is something we RECEIVE
rather than author. The existing guard only asserted the section HEADINGS were
present: a pull could empty a section of its normative content and stay green.
Measured, not assumed — with `escape, not depth` deleted from Step 1, the old
skeleton test still passes (M5 below).

Two new guards, one file:

- Rule level (test 6): 25 normative rules, each bound to the section that OWNS
  it, so a phrase surviving elsewhere in the document does not count as the spec
  still stating the rule. Selection is principled rather than taste — every entry
  anchors a §11 conformance seam or a CLAUDE.md invariant. Matching is
  whitespace- and emphasis-insensitive: re-wrapping a paragraph upstream is an
  editorial change, not a rule change, and must not cost a false red.
- §11 table (test 7): the conformance table still names the seams this suite
  implements, and every test it cites exists here. One-directional by design —
  the repo carries 26 load-bearing tests, the spec anchors 10, because the spec
  governs the METHOD, not this repo.

Five measurements, all as expected: M1 delete a rule → RED · M2 RELOCATE a rule
out of its section while leaving it in the document → RED (this is what the
section binding buys) · M3 drop a seam row from §11 → RED · M4 harmless re-wrap
+ de-bolding → GREEN · M5 the old skeleton test does NOT catch M1 → GREEN.
Spec restored from a byte copy after each mutation, shasum verified.

Known gap, deliberately not patched here: §11 has no row for the portfolio-wide
budget seam (S3.4/F10) — the pulled spec predates it. That is a commons
amendment; a consumer editing shared/ is exactly the drift this guards against.

553 → 555 tests, ruff + mypy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aw9TECznT5b5H6374CBTvV
2026-08-01 19:57:33 +02:00
a831aa1e3b feat(budget): enforce a global portfolio token cap before the call, not after it (S3.4/F10)
PortfolioBudget + PortfolioMeter carry ONE token ledger over a whole portfolio
pass -- and, seeded from a persisted spend file, across passes -- while the
per-run Budget/TokenMeter pair is untouched. Three enforcement points, each
doing a different job:

- startup: a remainder that cannot fund one run raises BudgetRefused before
  anything loads (a pass that can afford zero projects is a caller mistake,
  not a result);
- wave assembly: an unfundable project is NEVER STARTED and the pass stops
  structurally (budget_stop + stopped_early, completed runs preserved).
  Because every member of a wave is funded against the SAME pre-wave
  remainder, admission RESERVES each member's requirement -- otherwise a wave
  of k over-commits the cap by up to k runs;
- pre-call: BudgetMiddleware refuses a call the remainder cannot pay for
  instead of making it. The post-charge check stays: real usage is only
  knowable after the response, so the guard stops the NEXT call, never the
  one in flight.

budget_stop is its own field rather than a widened stop_reason -- a goal-stop
is success, this is resource exhaustion, and fusing them would make "we
stopped" unreadable. PortfolioMeter splits record/check so tokens the provider
already billed reach the ledger even when the same charge breaks the run's own
cap. read_spend raises on corrupt content (our own accounting state, unlike
the tolerant RAW inbox layer); write_spend takes a REQUIRED stamp with no
wall-clock default, mirroring promote_verdict.

Load-bearing MEASURED, not asserted -- 6 mutations, all red: detach the wave
check; detach the pre-call guard; detach the wave reservation; check the run
cap before crediting the global ledger; detach the startup refusal; make
read_spend tolerant. Files restored from shasum-verified copies after each.

Two findings worth keeping: the pre-call guard MASKS a detached wave check if
the test asserts on overspend (spend stays under the cap either way), so the
load-bearing assertion had to become failures == () plus never-started; and
the token arithmetic is probed (32 tokens/run at tokens=8), not guessed.

537 -> 553 tests, ruff + mypy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EaxFnaDAbMQkmTeX4u7sd
2026-07-31 21:34:48 +02:00
0d50ab89d3 feat(okf): conform the context seam to the pulled navigation + stamp-integrity contracts
The commons pull (7aa53fc -> a2b57d2) rewrote method-spec §3 Step 1 and added two §11
seams. Measuring okf.py against the new normative text found six contradictions; this
closes all six, gated by the commons-owned nav-goldens that came with the pull.

method-spec §3 Step 1 (navigate_bundle / bundle_context):
- follow cross-links RECURSIVELY, depth-first in first-seen order (there was no
  recursion at all — only the root index's links were read, so no hierarchy was
  navigable even with the other fixes in place);
- resolve a leading `/` against the BUNDLE ROOT, anything else against the LINKING
  file's directory, and drop the retired "a path separator means out-of-bundle"
  heuristic, which conflated depth with escape and forbade valid nesting;
- de-duplicate on the RESOLVED path (`./a.md` == `a.md` == `/a.md`), which is also
  what terminates cycles;
- exclude index files by BASENAME at every level, so a nested index is navigation and
  never renders as content (flat rendering regardless of depth);
- bind index_summary to the ROOT index alone.

safe_resolve stays the sole in-/out-of-bundle test, fail-closed: a target that fails to
resolve for ANY reason is skipped, never raised.

ingest-spec §3 (write_concept_file): it is the repo's one authoring primitive that
materialises a concept file from caller-supplied frontmatter, so it now refuses the
COMPLETE ownership stamp (`generated: true` + `ingest_manifest`) with IngestStampError,
while permitting either field alone. A validation, never a repair — nothing is written.

Gates (tests/test_okf.py, 529 -> 537):
- nav-golden-hierarchy and nav-golden-escape compared against the shipped
  expected-read-context.md fasit (trailing-whitespace normalisation only, which the
  fixture README explicitly permits; internal blank-line structure stays gated);
- traversal order pinned separately from the rendered output, so a right-looking render
  from a wrong walk still fails;
- unit seams for the recursion in isolation, resolved-path dedup, and the leading-`/`
  rule's breach case (a real out-of-bundle file addressed by its absolute path).

Load-bearing MEASURED, not asserted: seven mutations each go red — detach the recursion,
restore the separator prefilter, dedup on the raw target, read `/` as filesystem-absolute,
render nested index bodies as content, drop the stamp guard, and the fully naive navigator
with no boundary check (which is what makes the `/`-trap test bite). okf.py restored from a
checksum-verified copy after each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WetWTHpdRbqinN5XHFTaTb
2026-07-31 21:07:29 +02:00
bb877d241f docs(okf): re-ground the null-byte test's rationale on the resolve guard, not the retired heuristic
The commons pull (7aa53fc..a2b57d2) retires "a path separator means out-of-bundle"
in method-spec §3 Step 1. test_navigate_skips_null_byte_link stayed GREEN, but its
docstring explained itself via that heuristic ("slips past the `/` pre-filter") —
a live test citing dead doctrine. Behavior and assertions are unchanged; only the
reason is re-anchored on what the new spec makes load-bearing: a target failing to
resolve for ANY reason is skipped, not raised (§11 row "Navigation boundary").

Measured, not assumed: this was the only rotted rationale. The three other
separator mentions (okf.py:26-27, :125, test_okf.py:164) describe what the code
actually does or rest on safe_resolve, which survives the retirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxGWaoDYb6vmzd7EPfYkqS
2026-07-31 18:40:48 +02:00
16e1734264 docs(s33): document the wave snapshot's deliberate intra-wave semantics, pinned by test
Closes OQ1 (SC8). At k>1 every project in a wave reads the WAVE-START store,
so a verdict captured by project A does not reach project B's hypothesis
prompt inside the same wave — sequentially it would. Deliberate: the snapshot
is what removes the append race, and restoring intra-wave visibility would
restore the completion-order dependence the barrier exists to eliminate.
Learning flows ACROSS wave boundaries, not within them; concurrency trades
learning granularity for wall-clock.

The shipped fixture cannot show this (the fold is bundle_dir-gated and no
reference project sets bundle_dir), which is a property of the fixture and not
of the design — so it is pinned on the road-k + bundle-k+1 pair via the
existing load_reference_projects monkeypatch seam, no new production seam.
k=1 (two waves) carries the sentinel; k=2 (one wave) does not; store content
stays identical across k. Each half is the other's control.

Detach measured: remove _wave_snapshot -> RED on three tests including this one.

Also corrects two stale docstrings that outlived Session 1's finding: the
module header and the Step-2 test still named a sorted() in _merge_wave as the
detach point. There is no sorted() there, and a project_id sort would BREAK
the k=1-identity contract rather than protect it. A docstring naming a detach
point that does not exist is the green-but-dead defect this file exists to
prevent, so both now name the snapshot and mark the plan's claim as measured
wrong.

_wave_snapshot gains the honesty line about copying exactly two fields, with
the reason no field-count guard was added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:40:05 +02:00
796f8d3af0 feat(s33): wave-boundary goal-stop semantics + single-loop and one-writer guards
Documents the wave-model reading of the existing Step-8 goal semantics (a
reading, not a redesign — Session 1 already moved the checks to wave assembly
because the executor required it) and pins it with three tests.

Goal checks run at WAVE ASSEMBLY, per member: a HARD per-project goal removes
the pid before the wave starts, so an excluded project is never STARTED and
leaves no verdict behind; a HARD portfolio goal stops the pass with the
assembled wave still crossing its barrier; SOFT flags and continues.

Detach points measured:
  per-project goal keyed per WAVE, not per member -> RED (membership diverges
    at k=3, unaffected at k=1)
  import threading under src/                     -> RED (AST guard)
  promote_verdict called on the run path          -> RED (tripwire)

Corrects one claim the plan and my first docstring both implied: checking the
goal MID-WAVE does NOT make membership completion-order dependent.
_goal_limit_if_reached reads only the ledger, contract and baseline, all
invariant during a pass, so a later check reaches the same decision.
Determinism of membership is the ONE-WRITER rule's (C3, now a tripwire test),
not the check's placement; placement buys the never-started property. The
docstrings say this rather than claiming a detach point that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:33:17 +02:00
dd15e33556 feat(s33): collect-and-continue error policy via RunFailure slots
One project's exception no longer cancels its siblings: the wave's gather
runs with return_exceptions=True and each raised project becomes a frozen
RunFailure(project_id, error, error_type) in the defaulted
PortfolioResult.failures, while every completed project keeps its full
RunResult. RunFailure is a distinct type rather than an error field on
RunResult (six required non-defaulted fields -> dummies would be fabricated
provenance); the deviation from the spec's wording is stated in both
docstrings. TaskGroup is rejected: it cancels siblings on first exception.

snapshots reaches _merge_wave unfiltered, and results are paired back to pids
by POSITION (gather resolves in argument order) so a mid-wave failure cannot
disturb store order or misattribute the failure.

Detach points measured, not asserted:
  drop return_exceptions=True      -> RED (both new tests)
  reorder snapshots before merge   -> RED (+ Session 1's determinism test)
  pair results by sorted(), not position -> RED (failure misattributed)
  filter failed members before merge -> GREEN, measured

The last one corrects the plan: its carried-forward requirement implied
filtering before the merge was the hazard. Filtering preserves relative order,
which is all _merge_wave consumes, so the variant is undetectable AND harmless.
The docstring now names the reorder as the detach point and records the
filter asymmetry, rather than claiming a detach point that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:26:09 +02:00
756b1d5b5c feat(s33): wave executor with per-project snapshot and deterministic merge barrier
Replaces run_portfolio's sequential loop with a wave loop over _waves(ids, k):
each wave takes a per-project snapshot of the shared store, runs the wave under
one asyncio.gather in a single event loop, then crosses a merge barrier that
folds each project's NEW verdicts back in wave-submission order. Step 2's
contract goes GREEN; runs stays in project_ids order because gather resolves in
argument order, not completion order.

TWO PLAN CORRECTIONS, both found by the RED-first test rather than by reading:

1. The plan specified sorting the merged verdicts on `project_id`. Measured, that
   produces a deterministic order which is the WRONG one: lexicographic gives
   BRU/FV42/RV13 while the sequential pass gives FV42/RV13/BRU. It satisfies
   "deterministic" while breaking "identical to concurrency=1" — and the second is
   the actual contract. The merge preserves submission order instead.

2. The plan named the barrier's sort as the load-bearing seam. It is not — with
   per-project snapshots the wave list is never reordered by completion, so a
   sorted() there would re-sort an already-ordered list and read as a guard while
   guarding nothing. The SNAPSHOT is the half that carries the load. Rather than
   ship a decorative sort, both halves were measured (scratchpad-restore, never
   git checkout):

     detach _wave_snapshot  -> RED (store lands in completion order)
     detach merge ordering  -> RED (reversed wave order diverges)

   Both restored byte-identical (sha 42b01d46).

The snapshot carries `retriever` across deliberately: dropping it would silently
downgrade a caller-owned store's S3.1 semantic-retrieval opt-in mid-pass.

Also strengthens the scripted-client consolidation guard, which the probe broke by
being a legitimate third _inner_get_response def-site. It pinned a literal count
of 2 — the wrong shape: it failed on any new legitimate subclass while still
passing if someone pasted a duplicated body into an already-listed file. It now
pins the property (registered sites, scripted-lineage overrides must delegate via
super(), foreign-lineage doubles must genuinely be foreign). Verified load-bearing:
removing both delegation sites turns it RED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
2026-07-31 15:56:13 +02:00
5d0bb4c981 test(s33): pin the concurrent-equals-sequential contract — RED before the executor exists
Commits the determinism contract RED, mirroring S3.1 (b05747a RED -> 6618f67
GREEN). The wave executor does not exist yet, so `concurrency=3` still runs the
sequential path — and the test says so precisely rather than passing vacuously:

    max in-flight was 1: concurrency=3 never overlapped two projects
    call sequence=[FV42 x4, RV13 x4, BRU x4]

That blocked call sequence is the proof the probe works. A test that had gone
GREEN here would have been asserting something the sequential path already
satisfies — the Spike B call-counter tautology repeating — and the plan's
escalation rule would have stopped execution.

The probe returns a coroutine yielding N times via asyncio.sleep(0) (a pure
scheduler yield, no wall clock) before delegating to the canonical body, with N
read from the prompt blob since client_factory's argument is the ROLE, not the
project id. The stream path is delegated untouched, and the subclass chain is
preserved so BudgetMiddleware still engages.

Three self-checks make it non-vacuous by construction: max-in-flight > 1,
completion order != submission order, and >= 3 distinct verdict ids. Self-check
1 asserts non-identity rather than an exact reversal — asyncio.sleep(0) is a
scheduler yield, not an ordering primitive, so demanding a specific permutation
would only add a way to fail for a reason that is not the contract.

The docstring also corrects what the repetitions guard: NOT hash-seed
nondeterminism (PYTHONHASHSEED is fixed per interpreter, so every in-process rep
shares one seed), but scheduler nondeterminism under real concurrency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
2026-07-31 15:43:48 +02:00
c15165ca77 feat(s33): concurrency parameter + wave partitioning, k<1 fail-fast
Add keyword-only `concurrency: int = 1` to `run_portfolio`, a module-level
`_waves` partitioner, and a fail-fast on k < 1 that precedes any project load.

The execution loop is deliberately NOT changed here. At the default k=1 every
wave holds a single project, so the wave partition reproduces the existing
`for pid in ids` order by construction — the parameter is inert until Step 3
wires the executor. The 5 tests pin exactly that: order preservation across
every k, nothing dropped or duplicated, and the k<1 raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
2026-07-31 15:39:47 +02:00
b05747a3cd test(s31): pin the structural-order-preservation contract — RED at the shipped weight 2026-07-25 20:01:32 +02:00
5a6ee03955 test(s31): close 1 review MAJOR — guard the sole-numpy-importer premise the BLAS pin rests on 2026-07-25 19:56:39 +02:00
5481781ca5 test(s31): close 1 review MAJOR — guards cover the store functions and dynamic imports 2026-07-25 12:56:44 +02:00
fb4c593924 fix(s31): close 1 review MAJOR — VECLIB_MAXIMUM_THREADS + honest determinism claim + the missing pin guard 2026-07-25 12:52:52 +02:00
b9dd91cdbe fix(s31): close 1 review BLOCKER — EmbedderConfig registry + --embedder-config, never an import path 2026-07-25 12:50:19 +02:00
cabe05acf3 test(s31): close 1 review MAJOR — main()-level load-bearing proof of the --semantic-retrieval wiring 2026-07-25 12:44:03 +02:00
8e9f6603d7 fix(s31): close 1 review BLOCKER — refuse --semantic-retrieval when it cannot take effect 2026-07-25 12:39:42 +02:00
fc69285f2c fix(s31): close 1 review BLOCKER — per-call retriever + run_portfolio forwards semantic_retrieval 2026-07-25 12:37:15 +02:00
969d450b31 fix(s31): close 2 review findings — drop description from the embedding + re-derive SC2 on the minted shape 2026-07-25 12:31:06 +02:00
63734f5bfa feat(s31): --semantic-retrieval opt-in threaded through run_project/run_portfolio 2026-07-25 06:29:39 +02:00
6548828fe1 feat(s31): deterministic .npy+jsonl vector store with fail-fast shape check 2026-07-25 06:23:22 +02:00
5209775041 test(s31): hybrid cosine tie-break load-bearing seam (500+ base) 2026-07-25 06:20:55 +02:00
d2aeb741cc test(s31): register semretrieval MAF-free + runtime no-verdicts/no-network guard 2026-07-25 06:18:19 +02:00
ae684c89d3 feat(s31): Retriever seam — StructuralRetriever default + HybridRanker with injected similarity 2026-07-25 06:16:23 +02:00
466ff969fd feat(s31): semretrieval thread-pinned Embedder seam + fake embedder + cosine 2026-07-25 06:13:00 +02:00
7dab2dfb78 fix(s54): reject non-array/non-object ledger JSON in SavingsLedger.load
S5.4 review MINOR (SC5 fail-fast hole, run.py:740). A valid-JSON but
wrong-shape savings ledger escaped the --report fail-fast refusal:
  - top-level {} iterated zero keys -> entries=[] -> rc 0 "0,00 kr"
    (a malformed file masquerading as a real zero-savings result)
  - a bare scalar / object-with-keys / list-of-non-objects raised an
    uncaught TypeError -> traceback (violates SC5 "rc 1, no traceback")

Fix at the fail-fast boundary, not the run.py except tuple: the review's
first option (add TypeError to run.py:740) leaves the {} masquerade
because {} is an empty iteration, not a TypeError. SavingsLedger.load now
raises ValueError for a non-array top-level and a non-object row, caught
by run.py:740's existing ValueError arm. Hardens both callers
(run.py:740 report + run.py:785 portfolio).

RED-first: 6 unit cases (test_ledger) + 2 CLI rc-1 cases (test_run_cli).
452 passed; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-24 19:52:52 +02:00
19000d89f6 feat(s54): --report/--json CLI mode in run.py over value_report 2026-07-24 01:35:35 +02:00
9b81a6e416 test(s54): value_report overlap-flag load-bearing seam 2026-07-24 01:30:55 +02:00
dd566025a4 feat(s54): value_report text + deterministic JSON formatters 2026-07-24 01:29:31 +02:00
878c989f8c feat(s54): value_report roll-up builder over SavingsLedger accessors 2026-07-24 01:27:00 +02:00
5f8a91175a test(s53): main()-level --verdict-dir coverage (already-wired flags confirmed, not re-wired)
SC2 second half: --verdict-dir had no main()-level test (exploration gap). New test drops one
valid verdict into a tmp inbox and drives main([pid, --docs-dir, --bundle-dir, --verdict-dir,
--live-dry-run]) -> rc 0: the inbox ingestion (load_verdicts_from_dir, run.py:287) runs before the
dry-run cut (run.py:335), so the flag's wiring is exercised offline without raising. --bundle-dir's
main()-level coverage already exists in test_live_dry_run.py and is referenced, not duplicated.
run.py untouched (never re-wired). 11 passed in test_run_cli.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:46:01 +02:00
1b990f0887 feat(s53): CLI mode-exclusivity refusals (portfolio vs single-project partition)
After parse_args, validate mode consistency with structured refusals (rc 1, not argparse.error):
--portfolio + any single-project-only flag (--docs-dir/--bundle-dir/--verdict-dir/--outbox-dir/
--run-id/--live-dry-run) is refused naming the offending flag; --goals/--ledger outside --portfolio
is refused. --decision/--rationale are EXCLUDED (non-None defaults make explicit-vs-default
indistinguishable — Pass-2 #2; inert in portfolio mode, README says so). --dimension-config is
valid in both modes. Validation precedes the portfolio dispatch, so refusals fire before any load.
RED-first: (a)/(a')/(c) failed offline (rc 0 fall-through) before the check, green after; (b)/(b')
single-project guard + legacy backward-compat pin were already green post-Step-3. All refusal-arm
RED fall-throughs held OFFLINE (met-goal / --live-dry-run) — no socket, per brief NFR. 15 passed,
ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:44:23 +02:00
905b2f9a43 feat(s53): --portfolio mode + --goals/--ledger wiring, GoalReached observable in CLI output
main() gains a --portfolio mode flag dispatching to the EXISTING run_portfolio (not modified):
loads --goals/--ledger/--dimension-config via the fail-fast loaders, passes project_ids=(pid,)
or None (all reference projects), and prints a deterministic goal-stop line
'goal reached: scope=... project=... observed_ore=... limit_ore=... stopped_early=...' from the
returned GoalReached/PortfolioResult. Positional project_id relaxed to nargs='?' and --docs-dir to
optional, with a compensating single-project-mode guard (no pid/no --docs-dir -> rc 1 refusal) so
the legacy contract still fails loudly. Dispatch + guard sit BEFORE the live_dry_run branch
(Pass-2 #3 — appended after, they'd be dead code). Loader failures use the same structured refusal
('portfolio run refused: ...'). Load-bearing pair (RED-first): portfolio-hard goal already met
breaks offline before any client (scope=portfolio, stopped_early=True); per-project-hard control
skips the only pid (scope=project, stopped_early=False) — the two arms force the printed fields to
derive from run_portfolio's real values. Marker 13731 øre. 9 passed, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:40:35 +02:00
663639d376 feat(s53): --dimension-config/--outbox-dir/--run-id CLI flags + full-run structured refusal
main() single-project path now parses --dimension-config (fail-fast via load_dimension),
--outbox-dir, and --run-id (deliberate 7th companion flag: determinism invariant forbids a
wall-clock run_id default). All three threaded into BOTH run_project call sites. Full-run
branch wrapped in a structured-refusal (catch ValueError/FileNotFoundError/ValidationError ->
'run refused: {exc}' on stderr, rc 1, no traceback); the EXISTING dry-run handler widened to
the same tuple (pydantic ValidationError is not a ValueError subclass; load_dimension's
FileNotFoundError would otherwise traceback — Pass-2 #1). --outbox-dir carries a loud help=
note it must differ from --verdict-dir (self-contamination footgun; documented, not enforced).
RED-first: 4 CLI tests failed on unrecognized args, green after. 7 passed (incl. live-dry-run
regression). ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:36:21 +02:00
124b7aedde feat(s53): load_dimension fail-fast loader (MAF-free, mirrors load_goal_config)
New load_dimension(str | Path) in dimension.py: is_file() -> FileNotFoundError,
then Dimension.model_validate_json -> pydantic.ValidationError on malformed shape.
Fail-fast because dimension config is authoritative startup input (contrast the
tolerant verdict-inbox RAW layer). Stdlib + pydantic only — stays MAF-free
(test_okf_is_maf_free AST guard green). Exported from __init__.py in both the
import block and __all__. RED-first: 3 loader tests failed on the missing import,
green after. tests/test_dimension.py + test_okf.py: 29 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:31:32 +02:00
2ed0b5991c test(okf): lock link_in_index success-path byte/order preservation + honest shared_root claim
pkt.2 — coverage gap (green-but-dead): link_in_index preserves the existing
index byte-for-byte and in order on the SUCCESS path by construction, but
nothing asserted it. Byte-equality was checked only on the REFUSAL path
(test_step8_promotion_loadbearing.py Test A); other tests check mere line
MEMBERSHIP. A writer that kept every link but reordered/rewrote the existing
body would pass the whole suite — the exact ingest-spec §6 / Step-8 promotion
invariant we rely on (promoted verdict links survive re-ingest byte-for-byte).
New test uses deliberately non-sorted existing links; proven load-bearing (a
temporary `sorted()` reorder mutation flips it RED, then reverted). Test-only,
no production change.

pkt.4 — shared_root.py docstring overclaimed "Every MAF-side consumer resolves
through this ONE seam". Verified: both runtime consumers (persona, simulation)
do route through shared_root(); the 15 test modules hardcode the in-repo
fixture path deliberately (a test needing the real fixture must not be
redirected by a production env var). Scoped the claim to "runtime consumer"
rather than churning 15 test files to make a false claim true.

Full suite 415 passed, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVHLJuwBzp7MXkrUXJYARS
2026-07-21 09:38:56 +02:00
3b0ee792bd fix(retrieval): fail-closed on uncanonicalisable paths (embedded null byte)
is_within_dir called os.path.realpath OUTSIDE its try block, so a path with
an embedded null byte (which makes realpath raise ValueError, not OSError)
leaked that ValueError to callers. Via safe_resolve -> okf._load_file (which
catches only PathSecurityError) it propagated uncaught out of
navigate_bundle, breaking the OKF SPEC §4 guarantee (okf.py:8-9) that a
broken cross-link is silently skipped, never raised: an index link like
`](a\x00b.md)` has no `/`, slips past the same-dir pre-filter, and reached
path resolution.

Move both realpath calls inside the existing try so an uncanonicalisable
path is treated as not-within (fail-closed): safe_resolve raises
PathSecurityError -> _load_file returns None -> the link is skipped. Fixes
the class for both callers of the seam (navigate_bundle + retrieve).

Tests (load-bearing — RED before, GREEN after):
- test_navigate_skips_null_byte_link: the reported regression.
- test_null_byte_path_rejected: the seam directly (is_within_dir/safe_resolve).
- test_navigate_skips_bundle_escaping_symlink_link: closes the previously
  untested path-safety-at-link-resolution branch of navigate_bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVHLJuwBzp7MXkrUXJYARS
2026-07-21 08:00:11 +02:00
0a11af74a4 refactor(ingest): adopt shared llm-ingestion-okf v0.3.1 behind a thin adapter
Door A (manifest -> connector -> deterministic materialization -> index) is no
longer implemented here. src/portfolio_optimiser/ingest.py becomes a thin
consumer seam over the shared library, git-pinned to v0.3.1 on the same Forgejo
channel portfolio-optimiser-claude uses. Net -626/+385; ingest.py 599 -> 145 lines.

shared/ingest-spec.md remains the normative spec: the library implements it, it
does not replace it. Spec changes continue to go via commons.

Acceptance criterion met and proven: all three golden bundles (file/sql/http)
are byte-exact before and after, including the idempotence re-run. examples/ and
shared/ carry ZERO modifications -- the fasit was not adjusted to fit.

The rejection set was verified equivalent, not assumed: all 22 malformations the
repo's pydantic models refused are refused by the library, with typed codes
(okf_type_reserved, credential_embedded, extraction_id_duplicate, ...).

Test rebinding (invariants preserved, vehicle changed): the library has zero
runtime dependencies by design, so pydantic is unavailable to it.
ManifestV1.model_validate(dict) -> load_manifest_bytes(bytes); ValidationError ->
ManifestError; model_fields -> dataclasses.fields; PathSecurityError ->
SourceError(path_escape); ValueError -> MaterializationError(ingested_at_invalid).
Tests now also pin the refusal `code`, the library's documented stability
contract -- a sharper assertion than "some validation error was raised".

Two accepted behavioural deltas, recorded rather than silently dropped:
- Title whitespace is stored verbatim instead of collapsed at validation, so the
  frontmatter title and the index label are no longer guaranteed identical for
  irregular whitespace. Both behaviours are spec-conformant (the spec is SILENT;
  the old one was a repo-local pinned decision). Queued as a commons-amendment
  candidate so both stacks pin the same answer. Goldens unaffected.
- The section 8 audit log moves to logger llm_ingestion_okf.materialize. Nothing
  in the repo consumed the old channel.
Also: the `type` discriminator is no longer a dataclass field, so the spec
cross-check asserts it explicitly -- without that line the swap would have
silently narrowed the test.

New tests/test_ingest_library_seam.py pins the seam itself: the restated section 5
stamp formula against the stamp the library actually writes (the one place the
adapter does not purely delegate, since v0.3.1 exposes no stamp helper), the
local-only allow_network default, the list[Path] unwrapping, and a guard that the
adapter never regrows local Door A machinery. All four verified RED when detached,
as were both golden regressions under a byte-level render mutation.

Door A is UNGATED: it calls no guard before writing to disk. Gating untrusted
content remains the caller's responsibility (guard wiring still planned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B4jNN186eVqfe1x5DnTU6r
2026-07-20 07:47:55 +02:00
7ec60618b0 fix(s52): resolve ConsoleNotifier default stream at call time 2026-07-17 03:18:00 +02:00
cd8553c7ee test(s52): cover build_notifier console/file dispatch + file-without-path fail-fast 2026-07-17 03:16:25 +02:00
c48e2102d0 fix(s52): scrub every URL-bearing exception path in WebhookNotifier 2026-07-17 03:15:57 +02:00
8c252c1064 fix(s52): reject scheme-less webhook URL fail-fast in NotifierConfig 2026-07-17 03:14:36 +02:00
b84f4d46bb feat(s52): export Notifier public contract + sync extending.md B11 2026-07-16 19:53:57 +02:00
1267f6c3eb feat(s52): fail-fast NotifierConfig + build_notifier factory (egress gate threaded) 2026-07-16 19:51:57 +02:00
fde44ceeb6 test(s52): AST guard — socket calls confined to the _urllib_post seam 2026-07-16 19:50:21 +02:00
c782c3402c feat(s52): webhook errors never leak the secret-bearing URL 2026-07-16 19:49:24 +02:00
61c4cb2d26 feat(s52): webhook notifier with fail-closed opt-in gate + injectable transport 2026-07-16 19:48:02 +02:00
f206dfbf65 feat(s52): MAF-free nested payload builder (verdict_to_dict oracle) + file notifier 2026-07-16 19:46:27 +02:00
d5b583349a feat(s52): Notifier protocol + console notifier + MAF-free registration 2026-07-16 19:45:17 +02:00