Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,35 @@ versioned provenance sidecar;格式錯誤與任何輸出碰撞都會拒絕。
`fetched_rendered` 結果讓 `manifest`/`assemble` 使用通過驗證的本機快照,不再探測受保護
origin;URL、路徑、method 或 digest 不一致則 fail closed。

### 次級佐證:供應商補充說明

有些規範性資訊只存在於供應商的信件或通訊軟體訊息裡——金鑰怎麼取得、測試環境位址、
上線前必須完成的事——而供應商往往沒有打算把它寫進文件。這類內容可以人工摘錄成
Markdown 後匯入,標記為**次級佐證**:

```bash
uv run loop-apidoc import-supplementary-note \
--input ./notes/sandbox-key.md \
--from "engineer@provider.example" \
--received-at "2026-08-16T10:00:00+08:00" \
--subject "測試環境金鑰取得方式" \
--excerpted-by "carl" \
--sources ./sources
```

指令寫出摘錄本身與一份 `.source.json` sidecar,記錄出處、收到時間、摘錄者與 SHA-256;
`manifest` 讀 sidecar 把該來源標成 `authority: supplementary`,沒有 sidecar 的來源一律是
`normative`。供應商另外給的欄位對照試算表,另存為 Markdown 表格後走同一條路徑。

次級佐證做得到的事:被引用、填補 `missing`、讓一條主張成立。做不到的事:與正式文件
衝突時勝出、與正式文件在報告裡混為一談。凡是只靠次級佐證才成立的主張,都會在驗證
報告裡以 warning 級的 `SUPPLEMENTARY_SUPPORT` **逐條**點名(不是一則籠統的 run 層警告),
並計入文件品質分數的 source grounding。這類來源也不會進入 `record-fingerprint` 的指紋——
新鮮度比對的前提是來源可被重新取得,而一封信沒有 URL、沒有版本。

**要接受的代價**:摘錄是人寫的,摘錄者可能寫錯或過度解讀,pipeline 無法分辨。
sidecar 記下摘錄者是為了**可追責**,不是可驗證。這是這條路徑與其他所有來源的本質差異。

若 URL 本身就是 Swagger 2.0 或 OpenAPI 3.x JSON/YAML,請先把它固定為本機來源,而不是
走 HTML 導覽流程:

Expand Down
16 changes: 15 additions & 1 deletion docs/DESIGN_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ reads source-derived text. The inspector supports bounded UTF-8 Markdown, HTML,
JSON/YAML; PDF, Word, invalid UTF-8, oversized text, and other unscannable pending sources fail
closed until converted and re-manifested. It never rewrites evidence or echoes a matched payload;
the report retains at most 1,000 findings and uses a blocker truncation sentinel when more matches
exist, preventing bounded input from amplifying into unbounded output.
exist, preventing bounded input from amplifying into unbounded output. The ruleset covers both
directions a source can be dangerous in: whether it can *manipulate* the agent, and whether it
*leaks material to* the agent. Only self-evidencing secret material blocks in the second
direction — a competent API document necessarily documents its own `Authorization` header, and a
gate that always needs a waiver is not a gate. Warnings hold a budget separate from the shared
cap so that a document merely rich in contact addresses is never rejected by truncation alone.
The built-in `preprocess` seam handles `.docx` as a bounded OOXML package: it scans every Word XML
part and rejects unsafe ZIP/package/XML, macro or active DDE field content, external relationships,
unsupported alternate content, and merged-cell table semantics that cannot be rendered faithfully before writing
Expand Down Expand Up @@ -171,6 +176,15 @@ structured Markdown. Preparation reports, deterministic scores, version diffs, a
freshness reports help an operator understand readiness and change; they do not
fabricate claims or silently change validation semantics.

Some of those details exist, but only in supplier correspondence. A hand-written excerpt may be
imported as a **supplementary** source: it may be cited and may fill `missing`, but it never
outranks a formal document, never reaches `explicit_support`, and never enters the freshness
fingerprint — the dividing line is re-obtainability, not medium. Every claim resting only on such
a carrier is named individually in the validation report. The excerpt is written by a person, so
recording the excerpter buys accountability, not verifiability; that cost is accepted
deliberately, because excluding the carrier does not stop the information arriving — it only
stops it being recorded (ADR 0010).

Source-unstated deployment details such as a concrete server URL, authentication
details, or sandbox credentials remain structured governance gaps. Their absence from
an API document alone is not an integration-risk conclusion: review projections retain
Expand Down
13 changes: 10 additions & 3 deletions docs/PRODUCT_EXTENSION_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ their declared Applicability Envelope.
The repository already provides more than document conversion:

- bounded and reproducible source acquisition for local files, URLs, GitBook,
and direct OpenAPI snapshots;
direct OpenAPI snapshots, and hand-written excerpts of supplier correspondence
imported as **supplementary** sources (ADR 0010);
- deterministic pre-agent source-risk inspection, assembly, validation, scoring,
source quality, and run diffs;
- source freshness fingerprints and batch checks;
Expand Down Expand Up @@ -87,8 +88,14 @@ enter any agent context. After acquisition/preprocess, a manifest binds the exac
package and `inspect-source-risk` deterministically scans UTF-8 Markdown, HTML, and OpenAPI
JSON/YAML. Raw PDF/Word, invalid UTF-8, oversized text, and other unscannable pending sources
are blockers rather than implicit exceptions. Fixed findings identify only rules, source refs,
and locators; they never reproduce the matched payload or rewrite evidence. Reports retain at
most 1,000 findings and replace further matches with a fail-closed truncation blocker.
and locators; they never reproduce the matched payload or rewrite evidence. The ruleset covers
both directions — whether a source can manipulate the agent and whether it leaks secrets or
personal data to it — and among the leak rules only self-evidencing material (PEM private-key
blocks, JWTs) blocks, because a competent API document necessarily documents its own
`Authorization` header. Reports retain at most 1,000 findings and replace further matches with a
fail-closed truncation blocker; warnings hold a separate 500-entry budget whose overflow is a
warning, so a document that is merely rich in contact addresses is never rejected by truncation
alone.

The versioned report records schema/ruleset, `max_bytes`, manifest digest, per-source SHA-256,
and a stable source-binding digest. `assess-sources --source-risk` verifies and embeds that
Expand Down
112 changes: 112 additions & 0 deletions docs/adr/0010-supplementary-carriers-are-accountable-not-verifiable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
---
status: accepted
---

# Supplementary carriers buy accountability, not verifiability

Some normative information about a supplier's API exists only in correspondence — how to obtain
a sandbox key, where the test environment lives, what must be done before go-live, what a
merchant parameter actually means. It is not in any document, and the supplier often has no
intention of putting it there. The same is true of the field-mapping spreadsheet that arrives
alongside the manual.

Until now there were two options and both were bad. Leave it out: the information becomes
`missing`, the integration contract has a hole, but the report is honest. Or let the agent read
it and write it into the extraction: the contract is complete, and `provenance.json` now claims
a normative claim has source support that exists in no manifest entry. The second option makes
the report lie, and nothing in the pipeline would ever notice.

## The decision

Correspondence excerpts and re-saved spreadsheets enter as a third thing: a **supplementary**
source. They may be cited, may fill `missing`, and may make a claim stand. They may not outrank
a formal document, and they may not be indistinguishable from one in a report.

The level lives on the source, not on the claim. Being unre-obtainable is a property the carrier
has, and it holds for every claim that carrier ever supports; putting it on the claim would
restate the same judgement N times and give N chances to get it wrong. It is also deliberately
not folded into `derived_support` — that relationship means *inferential distance*, and mixing
carrier credibility into it would leave nobody able to tell which of the two a
`derived_support` meant.

The dividing line is re-obtainability, not medium. A supplier engineer's email is written by the
supplier, exactly as the PDF is; medium is not what makes it weaker. What makes it weaker is
that `freshness/` compares SHA-256 to detect drift and `governance/` triggers re-review from
that comparison, and an email has no URL, no version, and no second fetch. Anything that
`check-freshness` cannot periodically re-walk is supplementary, whatever it is made of.

## The breach we are accepting

An excerpt is written by a person. That person can transcribe wrongly, compress away a
condition, or read more into a sentence than it said — and no part of the pipeline can tell.
Every other source class in this repository is verifiable: the bytes are hashed, the fragment is
addressable, and a reader can go back to the original and check. This one is not.

`excerpted_by` is therefore not a verification mechanism. It records **who to ask** when a claim
turns out to be wrong. That is strictly weaker than what every other source offers, it is the
entire cost of this path, and it is the reason a supplementary source can never reach
`explicit_support` no matter how well written the excerpt is.

We accept it because the alternative is worse in a specific way. Excluding the carrier does not
make the information stop existing — it makes it arrive through an unrecorded channel, because
an engineer who knows how the sandbox key is obtained will type it into the extraction whether
or not there is a legitimate path for it. A recorded weak source beats an unrecorded one
masquerading as a strong one.

## What follows from it, and what does not

Two questions that looked like one turn out to be different, and they get different functions.

`source_guard` skips `source_violations` entirely when attribution is unambiguous, and it asks
`sole_normative_source()` — supplementary carriers ignored. Otherwise adding one excerpt to a
single-manual corpus would refuse the entire run at the input boundary, before a run directory
exists. Supporting material must never cost the manual its run.

`classify_item` asks `sole_source()`, which counts every usable document including supplementary
ones. Its fallback attributes an unresolvable locator to the one document present, and that
licence rests entirely on there being only one. An excerpt is a second document. Were it
excluded here, a citation reading "供應商信件 2026-08-16" would be recorded as `supported` by
`manual.md` — a claim attributed to a document that never made it, and `SUPPLEMENTARY_SUPPORT`
would stay silent because the citation now names the manual. That is precisely the failure this
distinction exists to prevent, reintroduced by the fix for it.

The asymmetry is the point: skipping a boundary check defers reporting to per-entry validation,
while attributing a locator asserts something about a document's contents. Only the first stays
safe once a second document exists. With an excerpt in the corpus, an unresolvable locator is
ambiguous and stays `UNVERIFIED` until the agent cites precisely.

A claim resting only on a supplementary source is named individually, as a warning-severity
`SUPPLEMENTARY_SUPPORT` at that plan item, and not as one run-level warning.
`SOURCE_FACTS_UNSCANNED` already demonstrated where run-level warnings end up: nine benchmark
cases carry it permanently and it now needs a paragraph of documentation explaining how to tell
its three causes apart. "The only basis for this normative claim is an email" must not become
background noise.

The plan item stays `supported`. `unverified` means the citation does not resolve to a manifest
source; an excerpt resolves fine — it has a file, a digest, and a sidecar. Reusing that status
would make its remedy ("re-read the affected scope and add the citation") advise something that
cannot be done, because there is nothing to re-read.

In `shadow` and `strict`, a supplementary carrier's support proposal and evidence reference are
withdrawn before the Core sees them. Filename-only legacy citations already degrade on their
own, so the exposure was the v1 exact reference: it owns its declared claim path and would
otherwise carry an excerpt into a Core candidate with the manual's standing. The withdrawal is
deliberately not a relabelling to `insufficient` — the Core model forbids a runtime from
proposing that, because insufficiency is Core's conclusion after verification, not a runtime
assertion. Removing the proposal lets Core reach that conclusion itself.

**Not decided here:** what happens when a supplementary source and a formal document disagree.
The intended rule is that the formal document wins without raising `SOURCE_CONFLICT`, since the
resolution is deterministic and needs no human judgement. The deterministic layer cannot enforce
it today: `source_conflicts[]` is free text declared by the extraction agent with no per-source
attribution, so nothing in `validate/` can tell which side of a conflict is supplementary.
Enforcing it requires extending the extraction schema, which is a separate change. Until then
the rule lives as guidance to the extraction agent.

**Falsified if:** a supplementary source becomes indistinguishable from a normative one at any
point where a claim's support is judged. Concretely, this decision no longer holds when
`loop_apidoc/agentcli/source_guard.py` stops asking `sole_normative_source()`, when
`loop_apidoc/validate/authority.py` stops naming supplementary-only claims individually, when
`loop_apidoc/shadow/bridge.py` lets a supplementary citation reach `explicit_support`, when
`loop_apidoc/freshness/record.py` fingerprints a supplementary source, or when
`loop_apidoc/manifest/models.py` drops `SourceAuthority`.
7 changes: 4 additions & 3 deletions docs/architecture-manual.en.html
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ <h2>Package Boundaries &amp; Module Architecture</h2>
<div class="command-chip">scaffold-extraction</div>
<div class="command-chip">normalize-html-snapshot</div>
<div class="command-chip">import-rendered-url</div>
<div class="command-chip">import-supplementary-note</div>
<div class="command-chip">related-url-pages</div>
<div class="command-chip">inspect-source-risk</div>
<div class="command-chip">assess-sources</div>
Expand Down Expand Up @@ -779,7 +780,7 @@ <h2>Package Boundaries &amp; Module Architecture</h2>
<div class="module-group">
<button type="button" class="module-card" data-module="manifest">
<div class="module-name">manifest/ <span class="pill io">Scan</span></div>
<div class="module-role">Scans sources, URLs and hashes to establish the provenance foundation.</div>
<div class="module-role">Scans sources, URLs and hashes to establish the provenance foundation, and takes each source's authority from its sidecar.</div>
</button>
<button type="button" class="module-card" data-module="extraction">
<div class="module-name">extraction/ <span class="pill agent">Data</span></div>
Expand Down Expand Up @@ -974,7 +975,7 @@ <h2>Package Boundaries &amp; Module Architecture</h2>
title: "manifest/",
badgeClass: "io",
badgeLabel: "Source Scan",
description: "Scans local sources and public URLs, recording format, size, hash and scan time as the basis for later provenance alignment.",
description: "Scans local sources and public URLs, recording format, size, hash and scan time as the basis for later provenance alignment. Each local source also carries an authority — normative or supplementary — declared by its same-named .source.json sidecar; no sidecar means normative. A supplementary source is a hand-written excerpt of supplier correspondence (email, chat, or a spreadsheet re-saved as Markdown): it may fill missing but never supports explicit_support, and it is omitted from the freshness fingerprint. The dividing line is re-obtainability. The sidecar read side fails closed: absence means normative, but a sidecar that cannot be read or does not match its source is refused (ADR 0010).",
seam: "build_manifest(sources_root, urls, generated_at)",
inputs: "sources root, URL list",
outputs: "manifest.json data structure",
Expand Down Expand Up @@ -1094,7 +1095,7 @@ <h2>Package Boundaries &amp; Module Architecture</h2>
title: "source_risk/",
badgeClass: "io",
badgeLabel: "File I/O",
description: "A deterministic, manifest-bound text-risk gate that runs before any agent reads sources. Markdown, HTML, and OpenAPI JSON/YAML are scanned; raw PDF/Word are unscannable and reject until converted. Reports never rewrite sources or echo matched payloads.",
description: "A deterministic, manifest-bound text-risk gate that runs before any agent reads sources, covering both directions: whether a source can manipulate the agent (Unicode tag, bidi override, control characters, instruction override) and whether it leaks material to the agent (secrets and personal data, reusing privacy.py's patterns). Among the leak rules only self-evidencing material blocks — SR-SECRET-VALUE is PEM private-key blocks and JWTs, while a credential reference stays a warning because a competent document documents its Authorization header. Warnings hold a separate 500-entry budget whose overflow is a warning, not a blocker. Markdown, HTML, and OpenAPI JSON/YAML are scanned; raw PDF/Word are unscannable and reject until converted. Reports never rewrite sources or echo matched payloads.",
seam: "inspect_source_risks(...) / load_verified_source_risk_report(...) / inspect-source-risk",
inputs: "exact source directory, manifest.json, optional max-bytes",
outputs: "source-risk-report.json, source-risk-report.zh-TW.md",
Expand Down
Loading