Skip to content

Commit 59af53d

Browse files
committed
Tranche-1.5: align SDK to accepted D137-D141, receipt conformance, example, pydocs
Per the coordination contract (protocol packages untouched; packages/sdk + python only): - Evidence types aligned to the accepted §5.5.7 schema: constraints are {type, status, expected, actual}, verifier gains version, payload refs accept wire nulls. Anchor registry constants added with the accepted D138 names (atrib-log, sigstore-rekor, rfc3161-tsa, opentimestamps). - D141 receipt surface: checkAttributionReceiptConsistency (TS) and a full Python parity module (parse + consistency check); both run the spec/conformance/mcp-extension receipt cases. The record-less log-submission case pins the checker's conservative no-record outcome alongside the corpus's shape-validity flag. - Runnable example at packages/integration/examples/client-sdk/ (attest -> chained revise -> recall round-trip on a temp mirror, daemon-first routing report, pass-through degradation), verified end-to-end; @atrib/sdk added to @atrib/integration deps. - Python API docs: pdoc in the [dev] extras, commands documented. Suites: TS 120 passed + 1 skip; Python 255 passed + 6 skips; mypy strict clean; doc-sync green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZsFiTVkgLp3BoBXQiJ3ba
1 parent 47eeb52 commit 59af53d

15 files changed

Lines changed: 591 additions & 14 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# @atrib/sdk client example
2+
3+
The consolidated client SDK in one runnable file: `attest()` writes signed
4+
context, `recall()` reads it back, and every operational failure degrades
5+
instead of throwing per
6+
[§5.8](https://github.com/creatornader/atrib/blob/main/atrib-spec.md#58-degradation-contract).
7+
8+
```bash
9+
ATRIB_PRIVATE_KEY=$(node -e 'console.log(Buffer.from(crypto.randomBytes(32)).toString("base64url"))') \
10+
npx tsx packages/integration/examples/client-sdk/integration.ts
11+
```
12+
13+
Run from the repo root after `pnpm install && pnpm --filter @atrib/sdk... build`.
14+
15+
## What it shows
16+
17+
1. **attest()** — an observation, then a revision chained to it via
18+
`ref: { kind: 'revises' }`. Both records sign through `@atrib/emit`'s
19+
`handleEmit` pipeline (no SDK-local signing implementation) and land in
20+
the local mirror with chain continuity (`chain_root` of the second
21+
record = record hash of the first).
22+
2. **recall()** — the history shape reading those records back, newest
23+
first, with `signature_verified` on each entry.
24+
3. **Daemon-first routing** — the client probes the local primitives
25+
runtime (`$ATRIB_PRIMITIVES_HTTP_ENDPOINT`, default
26+
`http://127.0.0.1:8796/mcp`) and reports which path served each call
27+
(`via: 'daemon' | 'in-process' | 'none'`). The example works with or
28+
without a running daemon.
29+
4. **Degradation** — a client built with `key: null` and no daemon
30+
returns a pass-through result with warnings instead of throwing.
31+
32+
The example writes its mirror to a temp directory (`ATRIB_MIRROR_FILE`)
33+
so it never touches `~/.atrib/records/`, and points log submission at an
34+
unroutable localhost anchor so nothing leaves the machine. Point
35+
`anchors` at `https://log.atrib.dev/v1/entries` (the default when the
36+
option is omitted) to submit real commitments.
37+
38+
The Python sibling (`python/`) exposes the same verbs; see
39+
[`python/README.md`](../../../../python/README.md).
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/**
4+
* Runnable @atrib/sdk walkthrough: attest → chained revise → recall,
5+
* daemon-first routing, and §5.8 degradation. See README.md for the
6+
* one-line run command. Uses a temp mirror and an unroutable log anchor
7+
* so nothing persists outside this process or leaves the machine.
8+
*/
9+
10+
import { mkdtempSync } from 'node:fs'
11+
import { tmpdir } from 'node:os'
12+
import { join } from 'node:path'
13+
import { createAtribClient } from '@atrib/sdk'
14+
15+
async function main(): Promise<void> {
16+
const mirror = join(mkdtempSync(join(tmpdir(), 'atrib-sdk-example-')), 'mirror.jsonl')
17+
// ATRIB_MIRROR_FILE is where attest's write path appends; ATRIB_RECORD_FILE
18+
// is where the in-process recall fallback (@atrib/recall) reads. Point both
19+
// at the same temp file so the example round-trips.
20+
process.env.ATRIB_MIRROR_FILE = mirror
21+
process.env.ATRIB_RECORD_FILE = mirror
22+
const contextId = 'e'.repeat(32)
23+
24+
const client = createAtribClient({
25+
contextId,
26+
// Unroutable anchor: submission stays local. Omit `anchors` (or use
27+
// https://log.atrib.dev/v1/entries) to submit real commitments.
28+
anchors: ['http://127.0.0.1:9/v1/entries'],
29+
})
30+
31+
console.log(`mirror: ${mirror}\n`)
32+
33+
// 1. Write an observation (the default attest kind).
34+
const observed = await client.attest({
35+
content: {
36+
what: 'chose sqlite over postgres for the pilot store',
37+
why_noted: 'single-node deployment constraint',
38+
},
39+
})
40+
console.log('observation:', observed.via, observed.record_hash)
41+
for (const warning of observed.warnings) console.log(' !', warning)
42+
43+
if (observed.record_hash === null) {
44+
console.log('\nNo signing key resolved — pass-through mode (§5.8 rule 5).')
45+
console.log('Set ATRIB_PRIVATE_KEY (see README.md) and re-run.')
46+
await client.close()
47+
return
48+
}
49+
50+
// 2. Revise it: one write verb, the ref discriminator picks the kind.
51+
const revised = await client.attest({
52+
content: {
53+
new_position: 'postgres after all',
54+
reason: 'pilot converted to multi-tenant',
55+
},
56+
ref: { kind: 'revises', record_hash: observed.record_hash },
57+
})
58+
console.log('revision: ', revised.via, revised.record_hash)
59+
60+
// 3. Read the chain back, newest first, signatures verified.
61+
const history = await client.recall<{
62+
records?: Array<{ record_hash?: string; event_type?: string; signature_verified?: boolean }>
63+
}>({ shape: 'history', context_id: contextId, limit: 5 })
64+
console.log(`\nrecall via ${history.via}:`)
65+
for (const entry of history.data?.records ?? []) {
66+
console.log(` - ${entry.event_type} ${entry.record_hash} verified=${entry.signature_verified}`)
67+
}
68+
69+
// 4. Degradation: no key + no daemon never throws.
70+
const degraded = createAtribClient({ daemon: { mode: 'off' }, key: null })
71+
const passThrough = await degraded.attest({ content: { what: 'nothing signs this' } })
72+
console.log(`\ndegraded attest: via=${passThrough.via} record_hash=${passThrough.record_hash}`)
73+
74+
await degraded.close()
75+
await client.close()
76+
}
77+
78+
main().catch((error) => {
79+
console.error(error)
80+
process.exitCode = 1
81+
})

packages/integration/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"@atrib/mcp": "workspace:*",
9191
"@atrib/mcp-wrap": "workspace:*",
9292
"@atrib/runtime-log": "workspace:*",
93+
"@atrib/sdk": "workspace:*",
9394
"@atrib/verify": "workspace:*",
9495
"canonicalize": "^3.0.0"
9596
},

packages/sdk/src/attribution.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// SPDX-License-Identifier: Apache-2.0
22

33
/**
4-
* `dev.atrib/attribution` extension receipts (P049 draft,
5-
* docs/adr-draft-p049-mcp-extension.md).
4+
* `dev.atrib/attribution` extension receipts (accepted as D141; extension
5+
* spec at docs/extensions/dev.atrib-attribution/v0.1.md, conformance at
6+
* spec/conformance/mcp-extension/).
67
*
78
* Behind an opt-in flag, the daemon client parses attestation receipts
89
* from tool results' `_meta["dev.atrib/attribution"]`: the propagation
@@ -15,7 +16,8 @@
1516
* signed records and inclusion proofs, never from the receipt itself.
1617
*/
1718

18-
import type { AtribRecord } from '@atrib/mcp'
19+
import { encodeToken, normalizeEventType, type AtribRecord } from '@atrib/mcp'
20+
import { recordHashRef } from './hashes.js'
1921

2022
export const ATTRIBUTION_EXTENSION_KEY = 'dev.atrib/attribution'
2123

@@ -87,3 +89,81 @@ export function parseAttributionReceiptBlock(meta: unknown): AttributionReceiptB
8789
? out
8890
: null
8991
}
92+
93+
/** Outcome of checking a receipt against its attached signed record. */
94+
export interface AttributionReceiptConsistency {
95+
/** True iff every receipt claim matches the attached record. */
96+
receipt_valid: boolean
97+
/** Receipt fields whose claims contradict the attached record. */
98+
mismatched_fields: string[]
99+
/** recordHashRef of the attached record (when a record is available). */
100+
attached_record_hash?: string
101+
/** The receipt's claimed record_hash (when present). */
102+
claimed_record_hash?: string
103+
}
104+
105+
/**
106+
* Check a receipt block's claims against the signed record they name
107+
* (the attached `block.record`, or a caller-retrieved record). Receipts
108+
* are advisory: a mismatch NEVER invalidates the tool result — it means
109+
* the receipt must not be trusted or cited (conformance:
110+
* spec/conformance/mcp-extension/cases/receipt--*.json).
111+
*
112+
* Compared claims: `receipt.record_hash` vs the record's canonical hash,
113+
* `token` vs encodeToken(record), and `creator_key` / `context_id` /
114+
* `chain_root` / `event_type` (short name or URI, normalized) vs the
115+
* record's fields. Absent receipt fields are not mismatches.
116+
*/
117+
export function checkAttributionReceiptConsistency(
118+
block: AttributionReceiptBlock,
119+
record?: AtribRecord,
120+
): AttributionReceiptConsistency {
121+
const attached = record ?? block.record
122+
const receipt = block.receipt
123+
const claimed = receipt?.record_hash
124+
if (!attached) {
125+
return {
126+
receipt_valid: false,
127+
mismatched_fields: ['record'],
128+
...(claimed !== undefined ? { claimed_record_hash: claimed } : {}),
129+
}
130+
}
131+
const mismatched: string[] = []
132+
let attachedHash: string | undefined
133+
let token: string | undefined
134+
try {
135+
attachedHash = recordHashRef(attached)
136+
token = encodeToken(attached)
137+
} catch {
138+
// A record that cannot be canonicalized/hashed cannot back a receipt.
139+
return {
140+
receipt_valid: false,
141+
mismatched_fields: ['record'],
142+
...(claimed !== undefined ? { claimed_record_hash: claimed } : {}),
143+
}
144+
}
145+
if (claimed !== undefined && claimed !== attachedHash) mismatched.push('record_hash')
146+
if (block.token !== undefined && block.token !== token) mismatched.push('token')
147+
if (receipt?.creator_key !== undefined && receipt.creator_key !== attached.creator_key) {
148+
mismatched.push('creator_key')
149+
}
150+
if (receipt?.context_id !== undefined && receipt.context_id !== attached.context_id) {
151+
mismatched.push('context_id')
152+
}
153+
if (receipt?.chain_root !== undefined && receipt.chain_root !== attached.chain_root) {
154+
mismatched.push('chain_root')
155+
}
156+
if (
157+
receipt?.event_type !== undefined &&
158+
normalizeEventType(receipt.event_type) !== normalizeEventType(attached.event_type)
159+
) {
160+
mismatched.push('event_type')
161+
}
162+
return {
163+
receipt_valid: mismatched.length === 0,
164+
mismatched_fields: mismatched,
165+
attached_record_hash: attachedHash,
166+
...(claimed !== undefined ? { claimed_record_hash: claimed } : {}),
167+
}
168+
}
169+

packages/sdk/src/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ export const DEFAULT_CALL_TIMEOUT_MS = 10_000
9797
export const DEFAULT_RETRY_COOLDOWN_MS = 30_000
9898
export const DEFAULT_PRODUCER = 'atrib-sdk'
9999

100+
/**
101+
* Accepted D138 anchor_type registry (spec §2.11.9). Only 'atrib-log'
102+
* (the default when absent) is submitted to today; the others activate
103+
* with the multi-anchor fan-out in the protocol packages.
104+
*/
105+
export const ANCHOR_TYPES = [
106+
'atrib-log',
107+
'sigstore-rekor',
108+
'rfc3161-tsa',
109+
'opentimestamps',
110+
] as const
111+
export type AnchorType = (typeof ANCHOR_TYPES)[number]
112+
100113
export function resolveDaemonEndpoint(config?: DaemonConfig): string {
101114
return (
102115
config?.endpoint ??

packages/sdk/src/evidence.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: Apache-2.0
22

33
/**
4-
* Evidence envelope types (P042 draft, docs/adr-draft-p042-evidence-envelope.md).
4+
* Evidence envelope types (accepted as D137; normative schema at spec §5.5.7).
55
*
66
* The SDK models evidence attachments on the universal envelope schema so
77
* downstream shapes align before the ADR lands: one envelope, N profiles
@@ -25,14 +25,15 @@ export type EvidencePayloadRefKind =
2525

2626
export interface EvidencePayloadRef {
2727
kind: EvidencePayloadRefKind
28-
/** For 'archive' / 'external' payload locations. */
29-
uri?: string
28+
/** For 'archive' / 'external' payload locations. Wire form uses explicit
29+
* null for absent (§5.5.7 example); both are accepted. */
30+
uri?: string | null
3031
/**
3132
* Set when the payload is itself a signed atrib record (may accompany
3233
* any kind except 'inline'); payload.hash then commits to that record's
3334
* canonical JCS bytes.
3435
*/
35-
record_hash?: string
36+
record_hash?: string | null
3637
}
3738

3839
export interface EvidencePayload {
@@ -46,9 +47,11 @@ export interface EvidencePayload {
4647
}
4748

4849
export interface EvidenceConstraint {
49-
name: string
50+
/** Profile-defined constraint discriminator (accepted §5.5.7 shape). */
51+
type: string
5052
status: 'passed' | 'failed' | 'unresolved' | 'not_checked'
51-
detail?: string
53+
expected?: unknown
54+
actual?: unknown
5255
}
5356

5457
export interface EvidenceEnvelope {
@@ -71,6 +74,7 @@ export interface EvidenceEnvelope {
7174
}
7275
verifier?: {
7376
name?: string
77+
version?: string
7478
checked_at_ms?: number
7579
}
7680
}

packages/sdk/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ export {
3838
export {
3939
DEFAULT_DAEMON_ENDPOINT,
4040
DEFAULT_PRODUCER,
41+
ANCHOR_TYPES,
4142
resolveAnchorSet,
4243
resolveDaemonEndpoint,
4344
type AnchorSpec,
45+
type AnchorType,
4446
type AtribClientConfig,
4547
type DaemonConfig,
4648
type DaemonMode,
@@ -61,10 +63,12 @@ export {
6163
} from './evidence.js'
6264
export {
6365
ATTRIBUTION_EXTENSION_KEY,
66+
checkAttributionReceiptConsistency,
6467
parseAttributionReceiptBlock,
6568
type AttributionLogSubmissionStatus,
6669
type AttributionReceipt,
6770
type AttributionReceiptBlock,
71+
type AttributionReceiptConsistency,
6872
} from './attribution.js'
6973

7074
// ── SDK hash helpers (compositions of @atrib/mcp primitives) ────────────

0 commit comments

Comments
 (0)