@carllee1983/dbcli 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1573,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
1573
1573
 
1574
1574
  **Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
1575
1575
 
1576
- > **Casting note:** Postgres returns `count(*)` and `sum()` as bigint (a string in the result set). `value ==` uses strict equality, so `"0" == 0` is false. Cast to `::int` (`count(*)::int`) to ensure numeric comparison works correctly.
1576
+ > **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
1577
1577
 
1578
1578
  ```bash
1579
1579
  dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
@@ -2721,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2721
2721
 
2722
2722
  | Flag | Purpose | Default |
2723
2723
  |---|---|---|
2724
- | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
2724
+ | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
2725
2725
  | `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
2726
2726
  | `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
2727
2727
  | `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
@@ -2729,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2729
2729
  | `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
2730
2730
 
2731
2731
  At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
2732
- contains a canonical SHA-256 integrity digest and `coverage.completeForDeclaredEvidence:
2733
- true` for its explicitly selected references.
2732
+ carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
2733
+ composing the same claims and references twice produces the same pack. `createdAt` records when the
2734
+ pack was written and is deliberately outside the digest — it is the one field a restamp can change
2735
+ without breaking validation. Whether a referenced source is still resolvable is reported by
2736
+ `evidence validate`, not stored in the pack.
2734
2737
 
2735
2738
  #### `evidence validate`
2736
2739
 
@@ -1573,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
1573
1573
 
1574
1574
  **Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
1575
1575
 
1576
- > **Casting note:** Postgres returns `count(*)` and `sum()` as bigint (a string in the result set). `value ==` uses strict equality, so `"0" == 0` is false. Cast to `::int` (`count(*)::int`) to ensure numeric comparison works correctly.
1576
+ > **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
1577
1577
 
1578
1578
  ```bash
1579
1579
  dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
@@ -2721,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2721
2721
 
2722
2722
  | Flag | Purpose | Default |
2723
2723
  |---|---|---|
2724
- | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
2724
+ | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
2725
2725
  | `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
2726
2726
  | `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
2727
2727
  | `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
@@ -2729,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2729
2729
  | `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
2730
2730
 
2731
2731
  At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
2732
- contains a canonical SHA-256 integrity digest and `coverage.completeForDeclaredEvidence:
2733
- true` for its explicitly selected references.
2732
+ carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
2733
+ composing the same claims and references twice produces the same pack. `createdAt` records when the
2734
+ pack was written and is deliberately outside the digest — it is the one field a restamp can change
2735
+ without breaking validation. Whether a referenced source is still resolvable is reported by
2736
+ `evidence validate`, not stored in the pack.
2734
2737
 
2735
2738
  #### `evidence validate`
2736
2739
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to dbcli are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-08-16 - Evidence that could not reproduce itself, and a hash that hid nothing
9
+
10
+ The evidence subsystem shipped in v1.53.0 and, until this week, nobody had composed a pack outside its own tests. The first real use — a `verify safe-backfill --after-write` against a live PostgreSQL — came back `not_verified` on data that was correct, and the audit that followed found three more defects of the same kind: an evidence pack whose digest covered a random UUID, so the same claims never produced the same pack twice; a receipt "fingerprint" that was an unsalted SHA-256 over eight possible values; and a blacklist comparison with no identifier boundaries, so a protected column named `id` refused any claim containing the word "identifier". Fixing them changes both published formats, which is what makes this a major release: **packs written by 2.x will fail validation under 3.0.0, and `observation.fingerprint` no longer exists.** The reversal that authorized the repairs — known defects get fixed whether or not anyone is using the code — is recorded in `docs/adr/0012-known-defects-get-fixed-whether-or-not-anyone-is-using-the-code.md`, superseding ADR 0011.
11
+
12
+ ### Changed
13
+
14
+ - **BREAKING: an evidence pack's digest now covers only its content, so equivalent input produces the same pack.** The digest was taken over the whole pack including `id`, which was `evp_${randomUUID()}`, and a millisecond `createdAt` — so composing the same claims twice yielded two unrelated digests and cross-run comparison was not merely hard but undefined. The digest now covers `version` / `subject` / `claims`; `id` is derived from its first 32 characters, so equivalent input yields an identical pack down to the identifier, and `parse` checks that the two agree. `createdAt` sits outside the digest and is documented, in the type and in `reference.md`, as the one field that can be restamped without breaking verification — leaving that unsaid would be selling tamper-evidence that isn't. `canonicalizeWithoutDigest` was `JSON.stringify`, so "canonical" rested on the build and parse paths hand-maintaining the same key insertion order; it is now a real canonicalization that sorts keys recursively. The format is changed in place with no v2 fallback and no compatibility layer: existing packs fail validation, which is correct, because their digests were computed under rules that no longer hold (#116).
15
+
16
+ - **BREAKING: a receipt states its observation instead of hashing it.** `observation.fingerprint` was an unsalted SHA-256 over a preimage space of eight values for `verify` (four `VerificationStatus` × an `artifactPersisted` boolean) and 2^(n+1) for an `assert` with n checks, in a fixed public serialization — a dictionary attack measured in milliseconds. Everything it covered already appeared in plaintext in `outcome`, so the only thing it protected was the per-check pass bit pattern, and the only reader it stopped was an honest one; being deterministic, it also let receipts be grouped by result across files, which is the property the old test suite was asserting. `verify` now records `{ kind: 'verify-outcome', status }` and `assert` records `{ kind: 'assert-verdict', checksPassed, checksTotal }` — counts, not positions, so *which* check failed still does not leave the receipt, and less leaks than before, since anyone willing to invert the old hash recovered the full bit pattern. `parseObservation` validates the field shape per operation and rejects `checksPassed > checksTotal` (#118).
17
+
18
+ - **`evidence` no longer means three incompatible things.** `VerificationStatus` (`verified` / `not_verified` / `indeterminate` / `blocked`), `EvidenceItem.status` (`ok` / `no-data` / `skipped` / `error` / `timeout`) and `WorkloadEvidence.state` (`available` / `absent` / `invalid` / `unavailable`) are a verdict on a subject, whether a diagnostic ran, and whether a source file is usable — three vocabularies that cannot be mapped onto each other, all filed under one word, all string unions the type system cannot keep apart. `EvidenceItem` is now `ReportFinding` and `WorkloadEvidence` is `WorkloadSource` (with `WorkloadEvidenceState`, `LoadWorkloadEvidenceOptions`, `loadWorkloadEvidence` and the command layer's `loadObservedWorkloadEvidence` renamed to match). The values are unchanged and `VerificationStatus` is untouched, being part of a published JSON contract. `CONTEXT.md` gains an "Outcome vocabularies" section stating that they do not map — but the rename is what stops the mistake at the call site, since nobody reads `CONTEXT.md` while writing a comparison (#114).
19
+
20
+ ### Added
21
+
22
+ - **`scripts/check-plan-acceptance.ts` makes an acceptance criterion say whether anything proved it.** The 2026-08-08 backlog's eight tickets shipped with v1.53.0 and still read `Status: Proposed`, and two of their criteria described behavior the code structurally could not produce. Nothing caught either, because plan documents are prose and prose drifts silently. Every numbered acceptance criterion under `docs/plans/*.md` must now end in `— covered by:` (naming a test file that exists), `— unverified:` (admitting nothing proves it), or `— known deviation:` (deliberately unmet, with a reason). The gate forces disclosure, not coverage — marking all thirty criteria `unverified` would pass — because a gate demanding real tests gets nagged into a rubber stamp, while the count stays visible in review, and eighteen of thirty unverified is its own pressure. The one substantive check is that a cited test file exists, that being the half that is cheap to check and the failure mode the gate exists to stop; prose criteria with no numbered list count as a violation, so reformatting cannot route around it. `docs/plans/done/` is not scanned, since rewriting closed plans to suit a later convention destroys the record this protects. `PLAN_ACCEPTANCE_EXEMPTIONS` is a ratchet shaped after `check-core-no-stdout.ts`: it may only shrink, and a contract test fails when an entry stops being needed (#113).
23
+
24
+ ### Removed
25
+
26
+ - **The `coverage` field is gone from the evidence pack rather than made writable.** Both writers hardcoded an empty gap list and the parser rejected a non-empty one, so the ticket's promise that an expired reference produces a coverage gap could never fire. A pack is immutable and a reference expires after composition, so the value could not be written back even in principle; `evidence validate` already reports staleness, and that is where a reader can act on it. A field that can never change says nothing (#116).
27
+
28
+ ### Fixed
29
+
30
+ - **`value == count` was never true on PostgreSQL, so `verify --after-write` could not report `verified` there.** `firstScalar` returned the driver's value as-is and `compare` used `===`, but pg returns `bigint` / `numeric` / `int8` as strings, so `"0" === 0` was permanently false — and a read-back after a write is almost always a count. The asymmetry is what made it hard to see: `>` and `<` went through JS coercion and passed, so on the same column `value > 5` worked while `value == 6` did not, with the output cheerfully printing `expected: "value == 6"`, `actual: "6"`, `pass: false`. Found by dogfooding a real backfill on PostgreSQL 16, where six rows were correctly updated and the assertion still said no (#115).
31
+
32
+ - **Blacklist matching in evidence content is bounded to whole identifiers, and its refusal says where.** Blocked terms were compared with `includes()` — no boundary, no minimum length — so a protected column named `id` refused any claim containing "identifier", "considered" or "valid", and the message was one line reading `evidence content contains a blocked identifier`, naming neither the field nor what it hit. A term now matches only when neither neighbour is a letter, digit or underscore, so `id` still hits `orders.id` and a bare `id` but not `identifier`, and regex metacharacters in a term are escaped, so `a.c` no longer matches `abc`. The keys of `blacklist.columns` — table names — were never collected into the term list at all, a silent hole in the other direction, and are now included. The error names the offending field (`subject.kind`, `claim 2 text`) and still does not name the term, since printing a protected identifier into a message the author may paste elsewhere is the thing the blacklist exists to prevent; claims are located by ordinal because the id may itself be the blocked string. Two limits are documented rather than papered over: `secret_customer` written as "secret customer" still gets through, which identifier matching against free prose cannot honestly promise to catch, and reference fields (audit `command`, receipt `path`) are not checked at all yet (#117).
33
+
34
+ - **The Elasticsearch integration suite had never asserted anything, on any machine.** It reported "container not running on port 9201" against a healthy container answering `200`: Elasticsearch's `GET /` carries no CORS headers, happy-dom's `fetch` drops a header-less response under the same-origin policy, `beforeAll` swallowed the error and every test returned early. It is the only adapter over HTTP, so it was the only casualty. Removing the global preload was tried and reverted — `tests/integration/ui-render-smoke.test.tsx` depends on it, and a per-file import leaks across the files Bun runs in one process, making the outcome depend on file order. `setup-happy-dom.ts` now saves the runtime `fetch` before `GlobalRegistrator.register()` and restores it after, exporting `runtimeFetch` so a test can pin the contract. Only `fetch` is restored: happy-dom's `window` / `document` / `Element` have no runtime counterpart to shadow, and no adapter touches `Request` or `XMLHttpRequest` (#109, #110).
35
+
36
+ - **A negative test asserting that extra fields are rejected no longer breaks `typecheck:tests`.** The object deliberately carrying a field the validator must refuse was written against a type that does not admit it, which `bun test` never sees and CI's `tsc` step fails on across all ten matrix jobs (#118).
37
+
38
+ ### Documentation
39
+
40
+ - **ADR 0005's `deferred` described the feature, not the decision.** Of ten ADRs it was the only one not `accepted`, so every inventory picked it back up as an open question — while its content is a settled fail-closed policy with a reopen checklist and a falsification condition already attached. What is deferred is SQD-05 / SQD-06. It is now `accepted`, with a paragraph separating "the policy is settled" from "provider generation is authorized", the latter having not happened — without it, a reader seeing `remain deferred` in the title over an `accepted` status would plausibly "fix" the status (#111).
41
+
42
+ - **The 2026-08-08 evidence backlog now matches what shipped.** Eight tickets read `Status: Proposed` under a spec header saying no implementation was authorized, a week after all of it went out in v1.53.0. Checking all thirty acceptance criteria against the test files found eleven genuinely asserted, eighteen unproven — mostly asserted halfway — and one structurally impossible. Tickets are marked Delivered with their known deviations and unverified criteria listed per line, and `CONTEXT.md` gains `Known deviation` (deliberately unmet) and `Unverified` (nobody proved it) as distinct terms, because merging them dilutes the first into "everything imperfect" (#112).
43
+
8
44
  ## [2.1.0] - 2026-08-16 - The gate asked the wrong question, and one route never reached it
9
45
 
10
46
  2.0.0 put a two-tier gate in front of raw SQL. This release is what measuring that gate against real servers found: one entire route into the database bypassed it, its qualification criterion asked whether a `WHERE` existed rather than whether it narrowed anything, and its notion of "what kind of statement is this" was the leading keyword — so a full-table delete wearing an `INSERT` or `WITH` in front of it was treated as routine. Each of the three is a way a statement that empties a table reached the database unattended, and each is closed here. The measurement the gate's own ADR bets on is now a command rather than a `jq` invocation somebody has to remember.
@@ -1573,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
1573
1573
 
1574
1574
  **Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
1575
1575
 
1576
- > **Casting note:** Postgres returns `count(*)` and `sum()` as bigint (a string in the result set). `value ==` uses strict equality, so `"0" == 0` is false. Cast to `::int` (`count(*)::int`) to ensure numeric comparison works correctly.
1576
+ > **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
1577
1577
 
1578
1578
  ```bash
1579
1579
  dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
@@ -2721,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2721
2721
 
2722
2722
  | Flag | Purpose | Default |
2723
2723
  |---|---|---|
2724
- | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
2724
+ | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
2725
2725
  | `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
2726
2726
  | `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
2727
2727
  | `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
@@ -2729,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2729
2729
  | `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
2730
2730
 
2731
2731
  At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
2732
- contains a canonical SHA-256 integrity digest and `coverage.completeForDeclaredEvidence:
2733
- true` for its explicitly selected references.
2732
+ carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
2733
+ composing the same claims and references twice produces the same pack. `createdAt` records when the
2734
+ pack was written and is deliberately outside the digest — it is the one field a restamp can change
2735
+ without breaking validation. Whether a referenced source is still resolvable is reported by
2736
+ `evidence validate`, not stored in the pack.
2734
2737
 
2735
2738
  #### `evidence validate`
2736
2739
 
@@ -51,7 +51,7 @@ var package_default;
51
51
  var init_package = __esm(() => {
52
52
  package_default = {
53
53
  name: "@carllee1983/dbcli",
54
- version: "2.1.0",
54
+ version: "3.0.0",
55
55
  description: "Database CLI for AI agents",
56
56
  type: "module",
57
57
  publishConfig: {
@@ -133,6 +133,7 @@ var init_package = __esm(() => {
133
133
  "platform:check": "bun run scripts/check-platform-parity.ts",
134
134
  "agent-core:check": "bun run scripts/check-agent-core-purity.ts",
135
135
  "core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
136
+ "plan:check": "bun run scripts/check-plan-acceptance.ts",
136
137
  typecheck: "tsc --noEmit --pretty false",
137
138
  "typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
138
139
  "test:perf": "bun test ./tests/perf/*.bench.ts",
@@ -41525,20 +41526,39 @@ var init_grammar = __esm(() => {
41525
41526
  });
41526
41527
 
41527
41528
  // src/core/assert/evaluator.ts
41529
+ function numeric(value) {
41530
+ if (typeof value === "number")
41531
+ return Number.isNaN(value) ? null : value;
41532
+ const trimmed = value.trim();
41533
+ if (trimmed === "")
41534
+ return null;
41535
+ const parsed = Number(trimmed);
41536
+ return Number.isNaN(parsed) ? null : parsed;
41537
+ }
41528
41538
  function compare(a, op, b) {
41539
+ let left = a;
41540
+ let right = b;
41541
+ if (typeof a !== typeof b) {
41542
+ const na = numeric(a);
41543
+ const nb = numeric(b);
41544
+ if (na !== null && nb !== null) {
41545
+ left = na;
41546
+ right = nb;
41547
+ }
41548
+ }
41529
41549
  switch (op) {
41530
41550
  case ">":
41531
- return a > b;
41551
+ return left > right;
41532
41552
  case ">=":
41533
- return a >= b;
41553
+ return left >= right;
41534
41554
  case "<":
41535
- return a < b;
41555
+ return left < right;
41536
41556
  case "<=":
41537
- return a <= b;
41557
+ return left <= right;
41538
41558
  case "==":
41539
- return a === b;
41559
+ return left === right;
41540
41560
  case "!=":
41541
- return a !== b;
41561
+ return left !== right;
41542
41562
  }
41543
41563
  }
41544
41564
  function firstScalar(result) {
@@ -41591,7 +41611,10 @@ function evaluateExpect(node, result) {
41591
41611
  };
41592
41612
  }
41593
41613
  if (pred.type === "between") {
41594
- const bad2 = nonNull.filter((v) => typeof v !== "number" || v < pred.low || v > pred.high);
41614
+ const bad2 = nonNull.filter((v) => {
41615
+ const n = numeric(v);
41616
+ return n === null || n < pred.low || n > pred.high;
41617
+ });
41595
41618
  return {
41596
41619
  name: `col:${column} between ${pred.low} and ${pred.high}`,
41597
41620
  expected: "0 out of range",
@@ -41622,7 +41645,7 @@ function compareVs(a, b, mode) {
41622
41645
  name: "vs:value",
41623
41646
  expected: String(bv),
41624
41647
  actual: String(av),
41625
- pass: av !== null && bv !== null && av === bv
41648
+ pass: av !== null && bv !== null && compare(av, "==", bv)
41626
41649
  };
41627
41650
  }
41628
41651
  var AssertShapeError;
@@ -41695,7 +41718,7 @@ function canonicalizeEvidenceReceiptCommand(command, operation = "assert") {
41695
41718
  function sha2563(value) {
41696
41719
  return `sha256:${createHash5("sha256").update(value).digest("hex")}`;
41697
41720
  }
41698
- function verdictFingerprint(verdict2) {
41721
+ function verdictObservation(verdict2) {
41699
41722
  if (!record3(verdict2) || typeof verdict2.pass !== "boolean" || !Array.isArray(verdict2.checks) || verdict2.checks.length > 1000) {
41700
41723
  throw new EvidenceReceiptValidationError("verdict must contain bounded pass bits");
41701
41724
  }
@@ -41705,26 +41728,30 @@ function verdictFingerprint(verdict2) {
41705
41728
  }
41706
41729
  return check.pass;
41707
41730
  });
41708
- return sha2563(JSON.stringify({ pass: verdict2.pass, checks: bits }));
41731
+ return {
41732
+ kind: "assert-verdict",
41733
+ checksPassed: bits.filter(Boolean).length,
41734
+ checksTotal: bits.length
41735
+ };
41709
41736
  }
41710
- function verificationOutcomeFingerprint(status2, artifactPersisted) {
41737
+ function verificationObservation(status2, artifactPersisted) {
41711
41738
  if (!isVerificationStatus(status2)) {
41712
41739
  throw new EvidenceReceiptValidationError("verificationStatus must be a verification status");
41713
41740
  }
41714
41741
  if (typeof artifactPersisted !== "boolean") {
41715
41742
  throw new EvidenceReceiptValidationError("verificationArtifactPersisted must be a boolean");
41716
41743
  }
41717
- return sha2563(JSON.stringify({ status: status2, artifactPersisted }));
41744
+ return { kind: "verify-outcome", status: status2 };
41718
41745
  }
41719
41746
  function buildEvidenceReceipt(input, options = {}) {
41720
41747
  const operation = input.operation === "verify" ? "verify" : "assert";
41721
41748
  const command = canonicalizeEvidenceReceiptCommand(input.command, operation);
41722
41749
  const context = parseContext2(input.context);
41723
- const { observationFingerprint, outcome } = input.operation === "verify" ? {
41724
- observationFingerprint: verificationOutcomeFingerprint(input.verificationStatus, input.verificationArtifactPersisted),
41750
+ const { observation, outcome } = input.operation === "verify" ? {
41751
+ observation: verificationObservation(input.verificationStatus, input.verificationArtifactPersisted),
41725
41752
  outcome: input.verificationStatus === "verified" && input.verificationArtifactPersisted ? "succeeded" : "failed"
41726
41753
  } : {
41727
- observationFingerprint: verdictFingerprint(input.verdict),
41754
+ observation: verdictObservation(input.verdict),
41728
41755
  outcome: input.verdict.pass ? "succeeded" : "failed"
41729
41756
  };
41730
41757
  const receipt = {
@@ -41741,10 +41768,7 @@ function buildEvidenceReceipt(input, options = {}) {
41741
41768
  verificationArtifactRef: input.verificationArtifactRef === undefined ? null : nullableId(input.verificationArtifactRef, "verificationArtifactRef")
41742
41769
  },
41743
41770
  replay: { status: "context-required" },
41744
- observation: {
41745
- kind: operation === "assert" ? "assert-verdict" : "verify-outcome",
41746
- fingerprint: observationFingerprint
41747
- }
41771
+ observation
41748
41772
  };
41749
41773
  return receipt;
41750
41774
  }
@@ -41789,9 +41813,7 @@ function parseEvidenceReceipt(raw) {
41789
41813
  throw new EvidenceReceiptValidationError("command provenance hash mismatch");
41790
41814
  if (!record3(raw.replay) || raw.replay.status !== "context-required" || Object.keys(raw.replay).length !== 1)
41791
41815
  throw new EvidenceReceiptValidationError("replay must be context-required");
41792
- if (!record3(raw.observation) || raw.observation.kind !== (operation === "assert" ? "assert-verdict" : "verify-outcome") || Object.keys(raw.observation).length !== 2)
41793
- throw new EvidenceReceiptValidationError("observation must match receipt operation");
41794
- const observationKind = operation === "assert" ? "assert-verdict" : "verify-outcome";
41816
+ const observation = parseObservation(raw.observation, operation);
41795
41817
  return {
41796
41818
  version: EVIDENCE_RECEIPT_VERSION,
41797
41819
  id: safeId(raw.id, "id"),
@@ -41806,10 +41828,26 @@ function parseEvidenceReceipt(raw) {
41806
41828
  verificationArtifactRef: nullableId(raw.provenance.verificationArtifactRef, "provenance.verificationArtifactRef")
41807
41829
  },
41808
41830
  replay: { status: "context-required" },
41809
- observation: {
41810
- kind: observationKind,
41811
- fingerprint: fingerprint(raw.observation.fingerprint, "observation.fingerprint")
41812
- }
41831
+ observation
41832
+ };
41833
+ }
41834
+ function parseObservation(raw, operation) {
41835
+ if (!record3(raw))
41836
+ throw new EvidenceReceiptValidationError("observation must be an object");
41837
+ if (operation === "verify") {
41838
+ exact(raw, ["kind", "status"], "observation");
41839
+ if (raw.kind !== "verify-outcome" || !isVerificationStatus(raw.status))
41840
+ throw new EvidenceReceiptValidationError("observation must match receipt operation");
41841
+ return { kind: "verify-outcome", status: raw.status };
41842
+ }
41843
+ exact(raw, ["kind", "checksPassed", "checksTotal"], "observation");
41844
+ const { kind, checksPassed, checksTotal } = raw;
41845
+ if (kind !== "assert-verdict" || !Number.isSafeInteger(checksPassed) || !Number.isSafeInteger(checksTotal) || checksPassed < 0 || checksTotal < checksPassed)
41846
+ throw new EvidenceReceiptValidationError("observation must match receipt operation");
41847
+ return {
41848
+ kind: "assert-verdict",
41849
+ checksPassed,
41850
+ checksTotal
41813
41851
  };
41814
41852
  }
41815
41853
  function inside(root, target) {
@@ -49868,11 +49906,19 @@ function assertVerificationSubjects(subject, claims) {
49868
49906
  throw new EvidencePackValidationError("verification evidence must match the claims subject kind");
49869
49907
  }
49870
49908
  }
49871
- function canonicalizeWithoutDigest(pack) {
49872
- return JSON.stringify(pack);
49909
+ function canonicalize(value) {
49910
+ if (value === null || typeof value !== "object")
49911
+ return JSON.stringify(value) ?? "null";
49912
+ if (Array.isArray(value))
49913
+ return `[${value.map(canonicalize).join(",")}]`;
49914
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, v]) => `${JSON.stringify(key)}:${canonicalize(v)}`);
49915
+ return `{${entries.join(",")}}`;
49873
49916
  }
49874
- function digest(pack) {
49875
- return createHash8("sha256").update(canonicalizeWithoutDigest(pack)).digest("hex");
49917
+ function digest(content) {
49918
+ return createHash8("sha256").update(canonicalize(content)).digest("hex");
49919
+ }
49920
+ function packId(contentDigest) {
49921
+ return `evp_${contentDigest.slice(0, 32)}`;
49876
49922
  }
49877
49923
  function parseEvidenceClaimsInput(raw) {
49878
49924
  if (!isRecord4(raw))
@@ -49907,49 +49953,52 @@ function buildEvidencePack(input, references, options = {}) {
49907
49953
  if (evidence2.some((reference) => reference.kind === "verification-artifact" && reference.subjectKind !== normalized2.subject.kind)) {
49908
49954
  throw new EvidencePackValidationError("verification evidence must match the claims subject kind");
49909
49955
  }
49910
- const base = {
49956
+ const content = {
49911
49957
  version: EVIDENCE_PACK_VERSION,
49912
- id: id(options.idFactory?.() ?? `evp_${randomUUID6()}`, "pack.id"),
49913
- createdAt: (options.now?.() ?? new Date).toISOString(),
49914
49958
  subject: normalized2.subject,
49915
- claims: normalized2.claims.map((claim) => ({ ...claim, evidence: evidence2 })),
49916
- coverage: { completeForDeclaredEvidence: true, gaps: [] }
49959
+ claims: normalized2.claims.map((claim) => ({ ...claim, evidence: evidence2 }))
49960
+ };
49961
+ const contentDigest = digest(content);
49962
+ return {
49963
+ ...content,
49964
+ id: packId(contentDigest),
49965
+ createdAt: (options.now?.() ?? new Date).toISOString(),
49966
+ integrity: { algorithm: "sha256", digest: contentDigest }
49917
49967
  };
49918
- return { ...base, integrity: { algorithm: "sha256", digest: digest(base) } };
49919
49968
  }
49920
49969
  function parseEvidencePack(raw) {
49921
49970
  if (!isRecord4(raw))
49922
49971
  throw new EvidencePackValidationError("evidence pack must be an object");
49923
- requireExactKeys(raw, ["version", "id", "createdAt", "subject", "claims", "coverage", "integrity"], "evidence pack");
49972
+ requireExactKeys(raw, ["version", "id", "createdAt", "subject", "claims", "integrity"], "evidence pack");
49924
49973
  if (raw.version !== EVIDENCE_PACK_VERSION) {
49925
49974
  throw new EvidencePackValidationError(`evidence pack version must be ${EVIDENCE_PACK_VERSION}`);
49926
49975
  }
49927
- if (!isRecord4(raw.coverage))
49928
- throw new EvidencePackValidationError("coverage must be an object");
49929
- requireExactKeys(raw.coverage, ["completeForDeclaredEvidence", "gaps"], "coverage");
49930
- if (raw.coverage.completeForDeclaredEvidence !== true || !Array.isArray(raw.coverage.gaps) || raw.coverage.gaps.length !== 0) {
49931
- throw new EvidencePackValidationError("v1 coverage must be complete for declared evidence with no gaps");
49932
- }
49933
49976
  if (!isRecord4(raw.integrity))
49934
49977
  throw new EvidencePackValidationError("integrity must be an object");
49935
49978
  requireExactKeys(raw.integrity, ["algorithm", "digest"], "integrity");
49936
49979
  if (raw.integrity.algorithm !== "sha256")
49937
49980
  throw new EvidencePackValidationError("integrity.algorithm must be sha256");
49938
- const base = {
49981
+ const content = {
49939
49982
  version: EVIDENCE_PACK_VERSION,
49940
- id: id(raw.id, "pack.id"),
49941
- createdAt: iso(raw.createdAt, "pack.createdAt"),
49942
49983
  subject: parseSubject(raw.subject),
49943
- claims: parseClaims(raw.claims, true),
49944
- coverage: { completeForDeclaredEvidence: true, gaps: [] }
49984
+ claims: parseClaims(raw.claims, true)
49945
49985
  };
49946
- assertVerificationSubjects(base.subject, base.claims);
49986
+ assertVerificationSubjects(content.subject, content.claims);
49947
49987
  const actualDigest = text4(raw.integrity.digest, "integrity.digest", 128);
49948
49988
  if (!/^[a-f0-9]{64}$/.test(actualDigest))
49949
49989
  throw new EvidencePackValidationError("integrity.digest must be sha256 hex");
49950
- if (actualDigest !== digest(base))
49990
+ const expectedDigest = digest(content);
49991
+ if (actualDigest !== expectedDigest)
49951
49992
  throw new EvidencePackValidationError("evidence pack digest mismatch");
49952
- return { ...base, integrity: { algorithm: "sha256", digest: actualDigest } };
49993
+ const packIdentity = id(raw.id, "pack.id");
49994
+ if (packIdentity !== packId(expectedDigest))
49995
+ throw new EvidencePackValidationError("evidence pack id does not match its content digest");
49996
+ return {
49997
+ ...content,
49998
+ id: packIdentity,
49999
+ createdAt: iso(raw.createdAt, "pack.createdAt"),
50000
+ integrity: { algorithm: "sha256", digest: actualDigest }
50001
+ };
49953
50002
  }
49954
50003
  function isInside(root, target) {
49955
50004
  const relativePath = relative5(root, target);
@@ -50072,7 +50121,9 @@ var init_evidence_pack = __esm(() => {
50072
50121
  // src/commands/evidence.ts
50073
50122
  var exports_evidence = {};
50074
50123
  __export(exports_evidence, {
50075
- evidenceCommand: () => evidenceCommand
50124
+ evidenceCommand: () => evidenceCommand,
50125
+ containsBlockedTerm: () => containsBlockedTerm,
50126
+ blockedTerms: () => blockedTerms
50076
50127
  });
50077
50128
  import { join as join42, relative as relative6, resolve as resolve12, sep as sep8 } from "path";
50078
50129
  import { realpath as realpath3 } from "fs/promises";
@@ -50095,24 +50146,34 @@ async function readClaimsInput(filePath) {
50095
50146
  function blockedTerms(config) {
50096
50147
  return [
50097
50148
  ...config.blacklist?.tables ?? [],
50149
+ ...Object.keys(config.blacklist?.columns ?? {}),
50098
50150
  ...Object.values(config.blacklist?.columns ?? {}).flat()
50099
50151
  ].map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0);
50100
50152
  }
50101
- function assertNoBlockedText(value, terms) {
50153
+ function containsBlockedTerm(value, terms) {
50154
+ const normalized2 = value.toLowerCase();
50155
+ return terms.some((term) => {
50156
+ const trimmed = term.trim().toLowerCase();
50157
+ if (trimmed.length === 0)
50158
+ return false;
50159
+ const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50160
+ return new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`).test(normalized2);
50161
+ });
50162
+ }
50163
+ function assertNoBlockedText(value, terms, field) {
50102
50164
  if (!value)
50103
50165
  return;
50104
- const normalized2 = value.toLowerCase();
50105
- if (terms.some((term) => normalized2.includes(term))) {
50106
- throw new EvidencePackValidationError("evidence content contains a blocked identifier");
50166
+ if (containsBlockedTerm(value, terms)) {
50167
+ throw new EvidencePackValidationError(`${field} contains a blocked identifier`);
50107
50168
  }
50108
50169
  }
50109
50170
  function assertClaimsSafe(raw, terms) {
50110
50171
  const input = parseEvidenceClaimsInput(raw);
50111
- assertNoBlockedText(input.subject.kind, terms);
50112
- assertNoBlockedText(input.subject.name, terms);
50113
- for (const claim of input.claims) {
50114
- assertNoBlockedText(claim.id, terms);
50115
- assertNoBlockedText(claim.text, terms);
50172
+ assertNoBlockedText(input.subject.kind, terms, "subject.kind");
50173
+ assertNoBlockedText(input.subject.name, terms, "subject.name");
50174
+ for (const [index, claim] of input.claims.entries()) {
50175
+ assertNoBlockedText(claim.id, terms, `claim ${index + 1} id`);
50176
+ assertNoBlockedText(claim.text, terms, `claim ${index + 1} text`);
50116
50177
  }
50117
50178
  }
50118
50179
  function sameReference(left, right) {
@@ -51302,7 +51363,7 @@ var init_change_set = __esm(() => {
51302
51363
  import { createReadStream } from "fs";
51303
51364
  import { lstat as lstat7 } from "fs/promises";
51304
51365
  import { createInterface as createInterface3 } from "readline";
51305
- async function loadWorkloadEvidence(options) {
51366
+ async function loadWorkloadSource(options) {
51306
51367
  const now = options.now ?? new Date;
51307
51368
  let info;
51308
51369
  try {
@@ -51683,7 +51744,7 @@ function semanticSchema(schema, blocked) {
51683
51744
  }
51684
51745
  return result;
51685
51746
  }
51686
- async function loadObservedWorkloadEvidence(path4, workspaceRoot, blocked) {
51747
+ async function loadObservedWorkloadSource(path4, workspaceRoot, blocked) {
51687
51748
  if (path4 === undefined) {
51688
51749
  return {
51689
51750
  state: "absent",
@@ -51693,7 +51754,7 @@ async function loadObservedWorkloadEvidence(path4, workspaceRoot, blocked) {
51693
51754
  issues: []
51694
51755
  };
51695
51756
  }
51696
- return loadWorkloadEvidence({ path: resolve14(workspaceRoot, path4), blockedIdentifiers: blocked });
51757
+ return loadWorkloadSource({ path: resolve14(workspaceRoot, path4), blockedIdentifiers: blocked });
51697
51758
  }
51698
51759
  function shouldFail(report, failOn) {
51699
51760
  if (failOn === "never")
@@ -51831,7 +51892,7 @@ var init_impact2 = __esm(() => {
51831
51892
  const contracts = await loadContractEvidence(workspaceRoot, semantic, baseline, savedQueries, blockedIdentifiers);
51832
51893
  const dataAccess = await loadDataAccessEvidence(workspaceRoot, semantic, baseline, savedQueries, blockedIdentifiers);
51833
51894
  const verifications = await loadVerificationEvidence(workspaceRoot, blockedIdentifiers);
51834
- const observedWorkload = await loadObservedWorkloadEvidence(options.events, workspaceRoot, blockedIdentifiers);
51895
+ const observedWorkload = await loadObservedWorkloadSource(options.events, workspaceRoot, blockedIdentifiers);
51835
51896
  const report = assessImpact({
51836
51897
  changes,
51837
51898
  semantic,
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@carllee1983/dbcli",
6
- version: "2.1.0",
6
+ version: "3.0.0",
7
7
  description: "Database CLI for AI agents",
8
8
  type: "module",
9
9
  publishConfig: {
@@ -85,6 +85,7 @@ var package_default = {
85
85
  "platform:check": "bun run scripts/check-platform-parity.ts",
86
86
  "agent-core:check": "bun run scripts/check-agent-core-purity.ts",
87
87
  "core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
88
+ "plan:check": "bun run scripts/check-plan-acceptance.ts",
88
89
  typecheck: "tsc --noEmit --pretty false",
89
90
  "typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
90
91
  "test:perf": "bun test ./tests/perf/*.bench.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carllee1983/dbcli",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Database CLI for AI agents",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -82,6 +82,7 @@
82
82
  "platform:check": "bun run scripts/check-platform-parity.ts",
83
83
  "agent-core:check": "bun run scripts/check-agent-core-purity.ts",
84
84
  "core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
85
+ "plan:check": "bun run scripts/check-plan-acceptance.ts",
85
86
  "typecheck": "tsc --noEmit --pretty false",
86
87
  "typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
87
88
  "test:perf": "bun test ./tests/perf/*.bench.ts",
@@ -1573,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
1573
1573
 
1574
1574
  **Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
1575
1575
 
1576
- > **Casting note:** Postgres returns `count(*)` and `sum()` as bigint (a string in the result set). `value ==` uses strict equality, so `"0" == 0` is false. Cast to `::int` (`count(*)::int`) to ensure numeric comparison works correctly.
1576
+ > **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
1577
1577
 
1578
1578
  ```bash
1579
1579
  dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
@@ -2721,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2721
2721
 
2722
2722
  | Flag | Purpose | Default |
2723
2723
  |---|---|---|
2724
- | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
2724
+ | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
2725
2725
  | `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
2726
2726
  | `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
2727
2727
  | `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
@@ -2729,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2729
2729
  | `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
2730
2730
 
2731
2731
  At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
2732
- contains a canonical SHA-256 integrity digest and `coverage.completeForDeclaredEvidence:
2733
- true` for its explicitly selected references.
2732
+ carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
2733
+ composing the same claims and references twice produces the same pack. `createdAt` records when the
2734
+ pack was written and is deliberately outside the digest — it is the one field a restamp can change
2735
+ without breaking validation. Whether a referenced source is still resolvable is reported by
2736
+ `evidence validate`, not stored in the pack.
2734
2737
 
2735
2738
  #### `evidence validate`
2736
2739
 
@@ -1573,7 +1573,7 @@ Opt-in flag trio that persists a **VerificationArtifact JSON** (schema v1) under
1573
1573
 
1574
1574
  **Planned vs Result evidence.** `dbcli skill tasks plan safe-backfill-verify --format json` returns a plan containing a `verification` block with `status: "planned"`. That block is the **planned** evidence definition — it describes which check will run. Running `assert --write-verification-artifact` on the actual data produces **result** evidence (`status: "verified"` or `status: "not_verified"`). The two records are distinct; `"planned"` does **not** indicate that verification has run or passed.
1575
1575
 
1576
- > **Casting note:** Postgres returns `count(*)` and `sum()` as bigint (a string in the result set). `value ==` uses strict equality, so `"0" == 0` is false. Cast to `::int` (`count(*)::int`) to ensure numeric comparison works correctly.
1576
+ > **Numeric note:** Postgres returns `count(*)` and `sum()` as bigint, which arrives as a string. A numeric expectation is compared numerically, so `value == 0` matches it without a cast. A quoted expectation stays a text comparison: `value == "0"` matches the text, not the number. The `::int` casts in the examples below are harmless and no longer required.
1577
1577
 
1578
1578
  ```bash
1579
1579
  dbcli assert "SELECT count(*)::int FROM orders WHERE status IS NULL" \
@@ -2721,7 +2721,7 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2721
2721
 
2722
2722
  | Flag | Purpose | Default |
2723
2723
  |---|---|---|
2724
- | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. | — |
2724
+ | `--claims <file>` | Required JSON file with exactly `subject` and `claims`; every claim has an `id` and plain-language `text`. SQL-shaped text, credentials, error content, and blacklisted identifiers are rejected. A blacklisted identifier has to appear as an identifier — a term like `id` matches `orders.id` but not the word "identifier" — and the refusal names the field, never the matched term. | — |
2725
2725
  | `--verification <selector...>` | One or more verification artifact ids, unique prefixes, filenames, or in-bounds paths. | none |
2726
2726
  | `--audit <selector...>` | One or more audit ids or unique prefixes (minimum four characters) from the active connection. | none |
2727
2727
  | `--receipt <path...>` | Explicit workspace-relative receipt path(s). Receipts contribute safe provenance only; they are not execution approval. | none |
@@ -2729,8 +2729,11 @@ dbcli evidence compose --claims ./claims.json --verification ver_abcd --audit 1a
2729
2729
  | `--format <json\|markdown>` | Compose receipt format printed to stdout. | `json` |
2730
2730
 
2731
2731
  At least one `--verification`, `--audit`, or `--receipt` reference is required. The resulting pack
2732
- contains a canonical SHA-256 integrity digest and `coverage.completeForDeclaredEvidence:
2733
- true` for its explicitly selected references.
2732
+ carries a canonical SHA-256 digest over its content, and its `id` is derived from that digest, so
2733
+ composing the same claims and references twice produces the same pack. `createdAt` records when the
2734
+ pack was written and is deliberately outside the digest — it is the one field a restamp can change
2735
+ without breaking validation. Whether a referenced source is still resolvable is reported by
2736
+ `evidence validate`, not stored in the pack.
2734
2737
 
2735
2738
  #### `evidence validate`
2736
2739