Metadata-Version: 2.4
Name: foretop-lading
Version: 0.1.0
Summary: Traces licence obligations across code dependencies and model weights as one dependency graph — resolves each artifact's licence via Hugging Face / PyPI and prints a flat, honest table (unknown rather than guessed).
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Dist: typer>=0.15
Requires-Dist: rich>=13.9
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic>=2.9
Requires-Dist: pyyaml>=6.0
Requires-Dist: pathspec>=0.12
Requires-Dist: huggingface-hub>=1.28
Requires-Python: >=3.12
Description-Content-Type: text/markdown

# lading

> Traces licence obligations across code, model weights, datasets and adapters as one
> dependency graph, and blocks only the policies the team explicitly chose.

Full product spec: `../../specs/lading.md`. This file tracks build status; the spec is the
source of truth for the eventual full scope.

## Status

**Slice 1 (Session 21): discover Python deps + model IDs in code, resolve via HF/PyPI, print
a flat obligations table.**

`lading` is `specs/lading.md`'s **Shape A — OSS CLI + CI check** product (§0: *"Ship the free
CLI publicly during Phase 0... The three-paid-pilot gate applies to the hosted tier only."*) —
same shape ebb/telltale/charter already shipped, so unlike undertow there's no `PILOT_STATUS`
tension to resolve first.

```
uv run --package foretop-lading lading scan <path> [--format table|json]
```

**Decisions locked in from `specs/lading.md` §1's checkpoint**, all as stated defaults —
`DEC-PAY`/`DEC-AUTH` are hosted-tier-only and untouched until `PILOT_STATUS >= 3`; `DEC-LLM`
matches what this session already established for undertow (Anthropic API, not used by Slice 1
itself — decomposition is Slice 2's job); `DEC-01`/`DEC-02`/`DEC-03`/`DEC-04`/`DEC-05`/`DEC-06`
had no cost/vendor tradeoff the way undertow's `DEC-LLM` did, so none were re-litigated
interactively — same "defaults locked in, flagged transparently" pattern charter's own Session
15 used.

**`discover_deps.py`** — parses `uv.lock`'s `[[package]]` entries via stdlib `tomllib` (no new
dependency). Scoped to `uv.lock` only — `specs/lading.md`'s own FR-001 also names pip/poetry
lockfiles and Node, but Slice 1's milestone text ("discover Python deps + model IDs") doesn't
name them; a documented gap, not a silent one. Sanity-checked against this monorepo's own real
`uv.lock` (100+ real packages), not just a hand-built fixture.

**`discover_models.py`** — line-based regex scan of `.py` files (via `keel.collect.walk.
iter_files`, third real consumer after ebb and telltale) for a `{namespace}/{name}`-shaped
string literal appearing on the same line as a recognized loader call (`from_pretrained`,
`pipeline(...)`, `hf_hub_download`, `snapshot_download`) — the same "good enough, not
semantically perfect" bound telltale's PromQL/OTTL matching and charter's rules-only classifier
already use, chosen to avoid false positives on unrelated slash-shaped strings (file paths,
URLs) without a full AST parser. Evidence carries `file:line`.

**A known, deliberate scope gap, found while smoke-testing against real loader calls**:
namespace-less canonical Hugging Face model ids (`gpt2`, `bert-base-uncased`,
`distilbert-base-uncased` — pre-namespace models that predate HF's `org/name` convention) are
**not** discovered, since they don't contain a `/` and are indistinguishable from any other
bare string literal without one. Requiring the `/` is what keeps false-positive noise down for
every other string in a real codebase; broadening the match would need a curated allow-list of
real bare model names, which is exactly the kind of curated-KB problem Slice 2's `DEC-02`
override layer is for, not this slice's regex.

**`resolve.py`** — `resolve_model`/`resolve_package` against the real, live Hugging Face Hub
API (`GET https://huggingface.co/api/models/{id}`) and PyPI JSON API (`GET https://pypi.org/
pypi/{name}/json`) — both fetched and verified live this session, not from memory. HF's own
`cardData.license` field can be a **string** (confirmed against the real, live
`meta-llama/Llama-3.2-1B` — a non-SPDX slug, `"llama3.2"`, Meta's own community-license name)
or a **list of strings** (confirmed against a real dataset card) — both shapes handled.
PyPI's own `info.license` is free text, not SPDX-validated, and inconsistent across the
ecosystem in practice — a real run against this monorepo's own `uv.lock` resolved `httpx`,
`anthropic`, `certifi` and others correctly, and left plenty genuinely `unknown` (packages that
declare their licence only via a PyPI trove classifier, which this slice doesn't read — a real,
expected metadata-quality gap, matching `specs/lading.md` §11's own "HF metadata quality is the
weak link" risk note, mirrored here on the PyPI side). Any failure — malformed id, 404, network
error, or a response with no licence field at all — resolves to `Resolution(licence=None,
resolved=False)`, never guessed, per `specs/lading.md` §4's own accuracy strategy. In-memory
dedup only within one run; no persistent cache yet (`specs/lading.md` §6's 7-day cache-expiry
rule is real but not this slice's job — a documented gap).

Both resolvers use a plain `httpx.Client()`, not `keel.fetch.make_safe_client()` — HF and PyPI
are two fixed, trusted hosts reached through a regex-validated id, not arbitrary vendor URLs
the way undertow's catalogue is; the SSRF-via-redirect concern `make_safe_client` exists for
doesn't apply the same way here. The id/name is still validated against a strict shape regex
before ever being interpolated into a URL, as a lighter defense-in-depth measure.

**`cli.py` / `render.py`** — `lading scan` prints one flat table (artifact, kind, licence,
resolved?, source URL), Rich-rendered with the same `rich.markup.escape` discipline every other
terminal renderer in this suite uses (a model id or package name is attacker-influenceable
source text — the same real bug class charter's Session 15 found and fixed). `--format json`
too. The table's own footer states "obligations to review, not legal advice" on every run —
`specs/lading.md` DEC-05's own framing, not optional.

Decomposing licences into the full obligation enum (attribution, share-alike, field-of-use,
...), the curated override KB, policy presets, datasets/adapters, local cache scanning, the
Mermaid graph, CycloneDX input, and the GitHub Action are all later slices per `specs/
lading.md` §8's own table — none of that is here yet.

**Verified for real, not just via mocks**: `uv run --package foretop-lading pytest apps/lading/
tests -q` (24 tests, including two live, unauthenticated network calls against the real HF and
PyPI APIs), `make check` (repo-wide: 636 tests, mypy strict, ruff) — all green. Ran
`lading scan` against a real scratch project containing this monorepo's own real `uv.lock` plus
real `transformers`-style loader calls for `meta-llama/Llama-3.2-1B`,
`cardiffnlp/twitter-roberta-base-sentiment-latest`, confirming real licences resolve
end-to-end against the live APIs (`llama3.2`, `cc-by-4.0`) in both table and `--format json`
output.

**Slice 2 (Session 22): obligation decomposition + curated KB + policy engine.**

```
uv run --package foretop-lading lading scan <path> [--format table|json] [--policy NAME_OR_PATH]
```

`specs/lading.md` §8's own milestone text: *"Obligation decomposition + curated KB for 30 top
models + policy presets."* Slice 1 only ever showed a bare licence string; `DEC-03` (locked in
Session 21, no cost/vendor tradeoff, not re-litigated) calls for decomposed obligations instead
of one verdict, because *"Llama's acceptable-use terms are not a copyleft question."*

**`obligations.py`** — `ObligationType`: `attribution`, `redistribution`, `acceptable_use`,
`field_of_use`, `share_alike`. Every `Obligation` carries a non-blank, verbatim `clause` —
`specs/lading.md` §9's own definition of done: *"No obligation is ever displayed without a
quoted clause and a working source link."* `SHARE_ALIKE` has no curated example yet — every
real licence family researched this session (Apache-2.0, MIT, Llama 3/3.1/3.2, Gemma,
CreativeML OpenRAIL-M, BigScience BLOOM RAIL, OPT-175B, DeepSeek, FLUX.1 [dev] Non-Commercial,
ChatGLM3) turns out to be genuinely permissive or RAIL-restriction-propagating, never true
copyleft — a documented gap, not an invented example.

**Curation is per licence *family*, not per model** (`licences.py` + `src/lading/data/
licences.yaml`) — 12 real families curated this session, grounded in licence text fetched
live (Apache-2.0's and CreativeML OpenRAIL-M's/BLOOM RAIL's raw text fetched directly;
Meta's and Google's community-license pages are JS-rendered, so their `clause` fields use the
fragments a fetch tool returned in direct quotation marks against the live page — flagged in
the YAML's own header comment as a starting point for review, not a final legal read, matching
`DEC-05`). Real, meaningful differences found between families researched side by side: Llama's
field-of-use gate is a 700M-monthly-active-users commercial threshold, OPT-175B's is
non-commercial-research-only, FLUX.1 [dev]'s is non-commercial-only, ChatGLM3's is
free-for-research/register-for-commercial — four genuinely different shapes a binary verdict
would have flattened into one. Gemma, confirmed against its own real text, has no MAU-style
threshold at all, unlike its Llama-shaped siblings.

**`model_overrides.py` + `data/model_overrides.yaml`** — `DEC-02`'s hybrid override layer: 6
real models whose Hugging Face `cardData.license` is missing or the generic `other` value,
each researched to its real licence this session (`openai/clip-vit-base-patch32` → MIT, via
OpenAI's own CLIP repo; `facebook/opt-125m` → OPT-175B License; `deepseek-ai/
deepseek-llm-7b-base` → the DeepSeek License Agreement; `black-forest-labs/FLUX.1-dev` → its
own Non-Commercial License; `THUDM/chatglm3-6b`/`zai-org/chatglm3-6b` → the ChatGLM3-6B
License, covering both the old and the renamed org id). `curate.py` checks this file *before*
ever calling the live HF API for a model — a curated override is trusted more than a live
lookup that would only report `other` anyway. `stabilityai/stable-diffusion-2-1` was
investigated and deliberately left uncurated: the real HF API returned a genuine 401 for this
specific repo's metadata endpoint in this environment (confirmed three ways — curl, Python's
`urllib`, and a fetch tool), so rather than guess its licence from memory it's left to resolve
honestly to `unknown`, exactly the behaviour `specs/lading.md` §4 asks for when a real lookup
fails.

**`policy.py`** — pure, total `evaluate(resolution, policy) -> PolicyResult`
(`specs/lading.md` §6: *"every (obligations, policy) pair produces a decision, never an
exception"*) plus `DEC-04`'s three presets (`permissive-only`, `no-copyleft`,
`no-field-of-use-restrictions`) and a YAML override (`load_policy`). An unresolved or
not-yet-curated licence defaults to `warn`, never `fail`, for every preset — `specs/lading.md`
§4: *"`unknown` is a policy-configurable outcome (default: warn, not fail)."* `--policy` is
opt-in; omitting it reproduces Slice 1's exact output and the exit code is always 0.
`permissive-only` correctly fails a real Llama-family model on its field-of-use obligation —
`specs/lading.md` §9's own definition-of-done line, verified against the live API (see below),
not just a synthetic test.

**Verified for real**: `uv run --package foretop-lading pytest apps/lading/tests -q` (70
tests), repo-wide `make check` (682 tests, mypy strict, ruff) — all green. Ran `lading scan
--policy permissive-only` against a real scratch project referencing
`meta-llama/Llama-3.2-1B` and `google-bert/bert-base-uncased`, plus a real `requests` dependency
— confirmed end-to-end against the live HF/PyPI APIs: `requests` (Apache-2.0) and
`bert-base-uncased` (apache-2.0) both `pass`, `Llama-3.2-1B` (`llama3.2`) correctly `fail`s on
its real field-of-use obligation, and the process exits 1. Also confirmed the wheel actually
bundles `data/*.yaml` (`uv build --wheel` + inspected the resulting zip) — the data files live
inside the package's own source tree the same way `apps/ebb/src/ebb/registries/retirements/
*.yaml` already does in production, not a new pattern.

**Slice 3a (Session 23): datasets, vendored adapters, local HF cache scanning.**

```
uv run --package foretop-lading lading scan <path> [--format ...] [--policy ...] [--scan-hf-cache]
```

`specs/lading.md` §8's Slice 3 milestone bundles four things: *"Datasets, adapters, local
cache scanning, Mermaid graph, CycloneDX input."* Split into this slice (the three discovery-
breadth additions, all plugging into the existing discover → curate → resolve → policy
pipeline) and a later Slice 3b (Mermaid + CycloneDX, output/interop concerns with no shared
code path) — the same "shrink to one coherent slice" call Session 20 made for undertow's own
Slice 3.

**New dependency, asked and approved this session**: `huggingface_hub` (real PyPI: 1.28.0,
Apache-2.0). `--scan-hf-cache` needs to parse the real `~/.cache/huggingface/hub` layout
(`models--org--name/snapshots/<rev>/...`, no-namespace repos, non-symlink Windows mode, `xet/`
chunk dirs that aren't repos at all — confirmed against Hugging Face's own real cache-docs,
fetched live) — rather than hand-roll a parser for edge cases HF already solved, `hf_cache.py`
wraps the official `scan_cache_dir()`. **Opt-in only** (`--scan-hf-cache`), matching charter's
own opt-in `--enumerate` convention — a bare `lading scan .` never silently reaches outside the
repo directory into a user's real, potentially tens-of-GB machine-wide cache.

**A real bug, found by live verification, not by theorizing**: `scan_cache_dir()` *raises*
`CacheNotFound` rather than returning an empty report when the cache directory doesn't exist
yet — the ordinary case for a fresh machine or CI runner that's never downloaded anything from
Hugging Face, not a rare edge case. This environment has no HF cache at all, and running the
real (unmocked) code against it crashed immediately. Fixed by catching `CacheNotFound` in
`hf_cache.py` and treating it as "nothing cached" — the same "a missing source is empty, never
an exception" rule every other discoverer in this suite already follows. A real, hermetic test
(`HF_HUB_CACHE` pointed at a guaranteed-nonexistent temp path, not relying on any given
machine's real cache state) locks this in.

**`discover_datasets.py`** — same line-based, loader-call-adjacent pattern as
`discover_models.py` (now factored into a shared `source_scan.py`, genuinely identical logic
between the two, not merely similar), matching real `load_dataset(...)` calls. Same documented
false-positive-bounding gap as models: a bare, namespace-less dataset id (`"glue"`, `"squad"`)
isn't discovered.

**`discover_adapters.py`** — walks the repo for a real, vendored `adapter_config.json`
(confirmed live this session against a real PEFT adapter repo,
`peft-internal-testing/tiny-OPTForCausalLM-lora`, that this file always carries
`base_model_name_or_path`). Emits both the adapter itself (`kind=adapter`, identifier = its
directory path — no HF id of its own until pushed) and its base model
(`kind=model`) as separate artifacts. An adapter id referenced directly from Python source
(`PeftModel.from_pretrained(base_model, "org/adapter-id")`) was already caught by
`discover_model_ids`'s own `from_pretrained` matching before this slice — confirmed live via
PEFT's own quicktour docs — so this only covers the genuinely new gap: a config file committed
to the repo with no matching source-code string nearby.

**New `ArtifactKind` members**: `DATASET`, `ADAPTER` — additive, matching `specs/lading.md`
§3's own domain model wording (`artifacts.kind: code|weights|dataset|adapter`) more closely;
the existing `PYTHON_PACKAGE`/`MODEL` members were kept as-is rather than renamed, to avoid
unnecessary churn to already-shipped code for a naming-only concern.

**3 new curated licence families** (`data/licences.yaml`, 15 total now) — the real Creative
Commons families datasets actually use, fetched live from CC's own legal code text: `cc-by-4.0`
(genuinely permissive, attribution only), `cc-by-sa-4.0` (this KB's **first real
`share_alike` example** — every family curated in Session 22 turned out to be permissive or
RAIL-restriction-propagating, never true copyleft), `cc-by-nc-4.0` (a real, distinct
`field_of_use` shape — non-commercial only, confirmed by `specs/lading.md` §9's own
definition-of-done example: *"a CC-BY-NC dataset"*). `Salesforce/wikitext`'s real, live
`cc-by-sa-3.0` (not `-4.0`, the only version curated) is a real, honest "not yet decomposed"
case, not a bug.

**Verified for real**: `uv run --package foretop-lading pytest apps/lading/tests -q` (93
tests), repo-wide `make check` (705 tests, mypy strict, ruff) — all green. Ran `lading scan`
against a real scratch project with a real `load_dataset("openai/gsm8k", "main")` call, a real
`from_pretrained("google-bert/bert-base-uncased")` call, and a real vendored
`adapters/my-lora/adapter_config.json` (real shape, live-fetched) — confirmed end-to-end
against the live HF API: the dataset resolved to `mit`, the model to `apache-2.0`, the adapter
correctly `unknown` (no HF id), and its base model (a real internal-testing repo with
genuinely no `cardData` at all, confirmed via a direct API check) correctly `unknown` too, not
a bug. `--scan-hf-cache` itself has **no real end-to-end run** in this session — this
environment has no local HF cache to scan (confirmed: `~/.cache/huggingface` doesn't exist
here) — so beyond the real `CacheNotFound` bug fix above, this path is mock-verified only; a
real run needs a machine with actual cached models/datasets, which is the user's own job to
confirm.

**Slice 3b (Session 24): Mermaid graph output + CycloneDX SBOM input.**

```
uv run --package foretop-lading lading scan <path> --format mermaid [--policy ...]
uv run --package foretop-lading lading scan <path> --sbom bom.json [--format ...]
```

The second half of `specs/lading.md` §8's Slice 3 milestone, split from Slice 3a since Mermaid
rendering and CycloneDX ingestion are output/interop concerns with no shared code path with
3a's discovery-breadth work — the same "shrink to one coherent slice" call Session 20 made for
undertow's own Slice 3.

**`render_mermaid`** (`render.py`) — `DEC-06`: *"Mermaid in the PR comment — reviewable
in-place, and it is the screenshot people share."* Wired into the existing `--format` flag as
a third value (`table|json|mermaid`), matching `specs/lading.md` FR-005's own framing of
Mermaid as a sibling output format, not a separate flag. One root node per scan fans out to
one node per artifact, coloured by `PolicyResult.decision` (green/yellow/red) via `classDef`/
`class` when `--policy` was supplied — confirmed live against mermaid.js.org's own current
docs as the non-deprecated syntax (`flowchart TD`, not the legacy bare `graph` keyword). Node
*ids* are synthetic (`n0`, `n1`, ...), never an artifact's own identifier — a real HF id like
`meta-llama/Llama-3.2-1B` would break Mermaid's own node-id syntax (no slashes allowed); the
real identifier only ever appears inside a quoted, escaped label. `_escape_mermaid_label`
applies the same defense-in-depth principle as `rich.markup.escape` elsewhere in this suite,
for Mermaid's own entity-escape syntax instead (`#` → `#35;`, `"` → `#quot;`, `#` escaped
first so its own escaped output is never re-escaped) — an artifact identifier or licence
string is text this process didn't originate and is never trusted not to contain characters
that would otherwise break the diagram.

**`discover_cyclonedx.py`** — accepts an existing CycloneDX 1.6 JSON SBOM as *input* via
`--sbom PATH` (additive to, never a replacement for, this command's own discovery — a team
composing lading with an existing SCA tool's SBOM still gets lading's own Python/model
discovery for anything the SBOM didn't cover). Real component shape and both supported purl
types were fetched live this session, not guessed: `pkg:pypi/{name}@{version}` (no namespace —
confirmed against cyclonedx.org's own real component examples and package-url/purl-spec's own
`pypi-definition.json`) and `pkg:huggingface/{namespace}/{name}@{revision}` (confirmed real
against purl-spec's own `huggingface-definition.json` and its test fixtures — a real ML-BOM
extension exists in CycloneDX 1.6, `type: "machine-learning-model"`, so a modern SBOM tool can
plausibly emit HF-model components, not just code packages). Every other purl type (npm,
maven, docker layers, ...) and any component with no purl at all is skipped, a documented gap
— real SBOMs commonly carry ecosystems lading doesn't resolve.

**A deliberate scope call, not a half-built feature**: a CycloneDX component's own already-
declared `licenses` field isn't consulted this slice. A parsed component becomes one more
artifact identifier feeding the exact same live resolve → curate → policy pipeline every other
discoverer uses, rather than a special-cased skip-the-live-call path — reusing the SBOM's own
declared licence directly would need a `DiscoveredArtifact` schema change to carry a pre-known
licence, real future work, not this slice's job.

**Verified for real**: `uv run --package foretop-lading pytest apps/lading/tests -q` (107
tests), repo-wide `make check` (719 tests, mypy strict, ruff) — all green. Ran
`lading scan --format mermaid --policy permissive-only` against a real scratch project (a real
`requests` dependency, a real `meta-llama/Llama-3.2-1B` reference) end-to-end against the live
HF/PyPI APIs: valid `flowchart TD` output, `requests` (Apache-2.0) coloured `pass`,
`Llama-3.2-1B` (`llama3.2`) coloured `fail` on its real field-of-use obligation, exit code 1 —
by eye, syntactically sane Mermaid. `--sbom` has no live-network verification of its own to run
(the SBOM file is local input, not a live API) — its purl-parsing correctness is covered by
real-shaped fixtures built from the fetched CycloneDX/purl-spec examples above, and its
resolve/curate/policy behaviour once parsed is exercised end-to-end with mocked HTTP; stated
plainly as mock-verified for that half, not claimed as more.

**Slice 4 (Session 25): GitHub Action, PR comment, CI gate, public mirror.**

`specs/lading.md` §8's last CLI-side milestone: *"GitHub Action, PR comment, CI gate,
Marketplace."* Not a novel design this session — ebb, telltale, and charter (all Shape A)
already built exactly this shape, read directly from their own code rather than re-derived
from memory, and replicated here with lading's own domain (obligations/policy decisions
instead of MCP capability drift or coverage regressions).

**Two new renderers** (`render/markdown.py`, `render/annotations.py` — `--format` now
`table|json|mermaid|markdown|annotations`), and **`render.py` split into a `render/` package**
(`terminal.py`, `json_renderer.py`, `mermaid.py`, `markdown.py`, `annotations.py`, `row.py`)
now that lading has reached the same 5-format count ebb's and charter's own `render/` packages
already use — not done earlier, since 3 formats didn't yet warrant it. `row.py` holds the
shared `Row` type alias in its own leaf module: `models.py` can't define it directly without
importing `curate.py`/`policy.py`, which already import from `resolve.py`, which imports from
`models.py` — a circular import `row.py` avoids by living outside that chain.

**`render_markdown`** mirrors `apps/charter/src/charter/render/markdown.py`'s own role
exactly — a PR-comment-ready document (table + summary) — plus the rendered Mermaid diagram
embedded in a ` ```mermaid ` fence, reusing `render_mermaid` directly rather than as a second,
separate CI step. This is what actually makes the graph *the* reviewable, shareable artifact
DEC-06 asks for, right inside the comment.

**`render_annotations`** emits `::error file=...,line=...::` GitHub workflow-commands — the
same zero-extra-permission mechanism (no `security-events: write`, no Advanced Security
requirement, unlike a SARIF-upload step) every other product in this suite already chose, for
the same reason, verified live once already and not re-verified a fourth time. One real
difference from charter's/telltale's own annotations: lading's `DiscoveredArtifact.evidence`
is a single string (Slice 1's own design), not a typed `(file, line)` pair, so a small
`_location` helper recovers the line number from a trailing `:digits` (the shape source-code
discovery produces) and falls back to line 1 for evidence with none (`uv.lock`, a CycloneDX
purl, `"hf cache: ..."`). Also unlike charter's/telltale's own drift-only annotations, *every*
current `fail` is annotated — lading has no `--base`/merge-base axis this CLI tracks yet, so
there's no "already existed" state to exclude.

**`apps/lading/action.yml`** — steps mirror charter's own exactly: scan with `--format
annotations` → render `--format markdown` for the sticky PR comment → upsert by marker
(`apps/lading/scripts/upsert_pr_comment.sh`, a byte-identical copy of the generic root-level
script) → fail on the captured exit code. `policy` defaults to `"permissive-only"` here, unlike
the bare CLI's own opt-in `--policy` — a CI Action whose whole purpose is being a gate can't
default to a silent no-op the way the general-purpose CLI reasonably can.

**Public mirror, matching `docs/adr/0002-ebb-public-mirror.md` and every predecessor's own
boundary exactly**: `.github/workflows/lading-{ci,release}.yml` (tag prefix `lading-v*`),
`scripts/mirror-workflows/lading-{ci,release}.yml`, and `scripts/sync-lading-mirror.sh` were
added. **Run for real against a scratch directory this session** (never against a real public
repo — same explicit warning charter's own script carries): first confirmed it reproduced the
same known packaging gap every predecessor's mirror had — `uv lock` failing because
`keel.collect.walk` only resolves inside this monorepo's own workspace — then, in a later
session, that gap was fixed for real across all four mirrors (ebb, telltale, charter, lading):
`scripts/sync-lading-mirror.sh` now vendors `keel.collect.walk` verbatim into the mirror at
sync time, and the mirror's own `pyproject.toml` (`scripts/mirror-pyproject/lading.toml`) no
longer depends on `foretop-keel`. See `packages/keel/README.md`'s own "Packaging note" for the
full mechanism. Re-verified for real after the fix: `ruff`/`mypy`/`pytest` (118 tests) all
green standalone, a real wheel built, and the built wheel cold-installed into a fresh venv with
`lading --help` actually running.

**Still the user's job, not this session's** (same split every predecessor used): creating the
`foretop-dev/lading` repo, actually running `sync-lading-mirror.sh` against it, creating the
`foretop-lading` PyPI project and configuring its trusted publisher (owner `foretop-dev`,
repository `lading`, workflow `release.yml`, environment `pypi`), cutting a `lading-v0.1.0`
release, and GitHub Marketplace submission — `docs/adr/0002` names Marketplace publishing
explicitly as *"a public launch action, deliberately left to the user."*

**Explicit non-goal this slice**: SARIF. Not named in this milestone's own wording (unlike
charter, where it arrived in an earlier slice) — same "match the milestone table's own words"
discipline used for every scope call this session.

**Verified for real**: `uv run --package foretop-lading pytest apps/lading/tests -q` (118
tests), repo-wide `make check` (730 tests, mypy strict, ruff) — all green; both new workflow
YAML files and `action.yml` parse as valid YAML. Ran `lading scan --format annotations
--policy permissive-only` and `--format markdown --policy permissive-only` against a real
scratch project (real `requests` + real `meta-llama/Llama-3.2-1B`) end-to-end against the live
HF/PyPI APIs: the annotation correctly points at `load.py:2` (the real line the
`from_pretrained` call is on) with the real field-of-use/acceptable-use reasons in its
message; the markdown output contains the correct table, summary, and a valid embedded
`flowchart TD` diagram.

**Session 28: `--format json` repurposed to emit the shared `Finding` schema.**

`SUITE_ARCHITECTURE.md` §3's keystone extraction (`packages/keel/src/keel/finding.py`,
Session 26) reached lading last of the three remaining adapters (telltale Session 26, charter
Session 27). `src/lading/findings.py`'s `to_findings()` maps a `Row`
(`DiscoveredArtifact`/`CuratedResolution`/`PolicyResult | None`) onto `Finding`, using
§3.1's own identity rule for lading — "artifact canonical id + obligation code" — as
`(artifact.kind, artifact.identifier, obligation.kind.value | None)`. Two finding kinds per
artifact, the same split charter's adapter used for (server, tool): one artifact-resolution
finding (`unknown` if unresolved or resolved-but-undecomposed, `clear` once decomposed,
regardless of obligation count) and one per `(artifact, obligation)` (`break` only when a
supplied `--policy` blocks that specific obligation; `clear` otherwise, including when no
`--policy` was given at all — judgment only happens when a policy exists to judge against,
mirroring charter's own "no `--base` ⇒ nothing is `break`" precedent).

**A real, breaking change, made deliberately, not by accident**: unlike telltale/charter (where
`json` didn't exist before their own adapter slice), lading already had a documented
`--format json` with its own row shape (identifier/kind/licence/obligations/decision). Asked
directly rather than assumed — the choice was to repurpose `--format json` in place to emit
the shared envelope, matching all three other adapters exactly, rather than add a second
format and leave lading as the one product where `json` doesn't mean `Finding`.
`PILOT_STATUS = 0` (nothing published/installed yet) made this the cheap side to choose: the
only real consumers were this repo's own tests, updated in the same commit.

**Evidence is genuinely heterogeneous** across lading's five discoverers — an absolute
lockfile path, a relative `path:line`, a bare purl, an `"hf cache: <path>"` descriptor — unlike
ebb/telltale/charter, which all have a real `source_file`/`source_line` pair to anchor on.
`_artifact_evidence()` parses defensively: strip a trailing `:<line>` if present, hash the real
file's bytes if the remaining text resolves to one, and otherwise fall back to hashing the
evidence string itself — still a real, deterministic, non-fabricated hash, just of the
descriptor text instead of file content when there is no file to hash.

**`rule_version` for an obligation finding** is the owning `LicenceEntry.verified_at` date
(ISO format), not a fabricated counter — `licences.yaml` has no taxonomy-wide version field the
way charter's `capability_taxonomy.yaml` does, and the per-entry verification date is a more
honest signal anyway.

Verified the same way Sessions 26/27 were: `lading scan --format json` against a real scratch
project (mocked HF/PyPI, same fixtures `test_cli.py` already builds) diffed key-for-key against
a fresh `ebb scan --format json` — identical envelope, four products now. `--policy
permissive-only` against the real `meta-llama/Llama-3.2-1B` field-of-use obligation produced a
`break` finding; the identical scan with no `--policy` produced `clear` for the same
obligation, identity unchanged across both runs.
