From 4999ad13d115fa886b8f9a6aa9f2ccc6785eef64 Mon Sep 17 00:00:00 2001 From: Kames Date: Mon, 10 Aug 2026 13:10:47 -0700 Subject: [PATCH] feat(primitives): build canonical IID atom anchors (P05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add buildIidAnchor, the supported high-level path from a classification's field values to on-chain-ready atom bytes: interprets the declarative identity ladder through @0xintuition/iid, selects the strongest legal representation profile (P0 when anchor-eligible, otherwise a deterministic P1 payload with frozen key order carrying @type, identifier, and the fired recipe's fields), computes the atom ID through @0xintuition/ids, and returns the registry's classification and ordered provider plan. - Class C and polymorphic-scheme identifiers floor at P1; requesting p0 for them is a structured error, never a silent downgrade. - Add ordered, trimmed, exact-deduplicated URI-context manifest construction with offline policy limits (5 x 700 bytes) and optional caller-supplied live limits; URI context never changes atom bytes or atom ID. - Add recognizeAtomData: classifies canonical IID anchors vs JSON payloads without reclassifying arbitrary strings; isValidAtomData now accepts valid canonical IID anchors as atom data. - Add exact deps @0xintuition/iid@0.1.0-alpha.0 and @0xintuition/iid-registry@0.1.0-alpha.0, the ./anchor subpath export, README section, and 10 anchor tests including the golden P0 ISRC byte/ID fixture (140 primitives tests total). - Legacy JSON-LD builders unchanged; all existing snapshots stable. Identity impact: none — existing golden bytes unchanged; new golden P0 ISRC fixture values generated from the shipped ids package and pinned in tests. Co-Authored-By: Claude Fable 5 --- bun.lock | 4 +- packages/primitives/README.md | 26 ++ packages/primitives/package.json | 6 + .../primitives/src/__tests__/anchor.test.ts | 177 ++++++++++++++ packages/primitives/src/anchor.ts | 229 ++++++++++++++++++ packages/primitives/src/index.ts | 10 +- packages/primitives/src/types.ts | 59 +++++ packages/primitives/src/validate.ts | 24 +- 8 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 packages/primitives/src/__tests__/anchor.test.ts create mode 100644 packages/primitives/src/anchor.ts diff --git a/bun.lock b/bun.lock index a089ee3..f915317 100644 --- a/bun.lock +++ b/bun.lock @@ -146,8 +146,10 @@ "name": "@0xintuition/primitives", "version": "0.1.0-alpha.1", "dependencies": { - "@0xintuition/classifications": "0.1.0-alpha.0", + "@0xintuition/classifications": "0.1.0-alpha.1", "@0xintuition/ids": "0.1.0-alpha.0", + "@0xintuition/iid": "0.1.0-alpha.0", + "@0xintuition/iid-registry": "0.1.0-alpha.0", "@0xintuition/predicates": "0.1.0-alpha.0", }, "devDependencies": { diff --git a/packages/primitives/README.md b/packages/primitives/README.md index ba26ff0..03149d7 100644 --- a/packages/primitives/README.md +++ b/packages/primitives/README.md @@ -26,3 +26,29 @@ if (!account.success) throw new Error(account.errors.join(', ')) const claim = buildTripleByName(account.value.id, 'follow', account.value.id) if (!claim.success) throw new Error(claim.errors.join(', ')) ``` + +## IID atom anchors + +`buildIidAnchor` is the supported high-level path from a classification's field values to canonical, identifier-first atom bytes ([IID spec](https://www.npmjs.com/package/@0xintuition/iid-spec)). It interprets the classification's identity ladder, selects the strongest legal representation profile, serializes deterministic bytes, computes the atom ID, and returns classification/provider hints plus an ordered URI context manifest. + +```ts +import { buildIidAnchor } from '@0xintuition/primitives/anchor' + +const anchor = buildIidAnchor( + 'music-recording', + { isrc: 'US-RC1-76-07839' }, + { contextUris: ['https://app.example.com/track/1'] }, +) +if (!anchor.success) throw new Error(anchor.errors.join(', ')) + +anchor.value.iid // 'int:isrc:USRC17607839' — canonicalized +anchor.value.profile // 'p0' — bare anchor: atomId is a pure function of the IID +anchor.value.data // 'int:isrc:USRC17607839' (exact atom bytes) +anchor.value.id // deterministic atom ID +anchor.value.contextUris // ordered, deduplicated; NEVER affects the atom ID +anchor.value.providerPlan // ['musicbrainz', 'spotify', 'apple-music'] +``` + +Rules the builder enforces: Class C (`gen1`) and polymorphic-scheme identifiers floor at P1 (their `@type` and recipe fields travel in the payload — requesting `p0` is a structured error, never a silent downgrade); URI context is validated against policy limits offline (pass live `getAtomUriConfig` values via `uriLimits`); equivalent reordered input produces identical bytes. The builder never calls wallets, contracts, or enrichment providers. + +Legacy JSON-LD builders (`buildAtom`, `buildPerson`, …) are unchanged and remain the explicit compatibility path for descriptive atom data. `recognizeAtomData` distinguishes canonical IID anchors from JSON payloads without reclassifying arbitrary strings. diff --git a/packages/primitives/package.json b/packages/primitives/package.json index b86afff..e326591 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -42,6 +42,10 @@ "./types": { "types": "./dist/types.d.ts", "import": "./dist/types.js" + }, + "./anchor": { + "types": "./dist/anchor.d.ts", + "import": "./dist/anchor.js" } }, "keywords": [ @@ -86,6 +90,8 @@ "dependencies": { "@0xintuition/classifications": "0.1.0-alpha.1", "@0xintuition/ids": "0.1.0-alpha.0", + "@0xintuition/iid": "0.1.0-alpha.0", + "@0xintuition/iid-registry": "0.1.0-alpha.0", "@0xintuition/predicates": "0.1.0-alpha.0" }, "peerDependencies": { diff --git a/packages/primitives/src/__tests__/anchor.test.ts b/packages/primitives/src/__tests__/anchor.test.ts new file mode 100644 index 0000000..ff986d4 --- /dev/null +++ b/packages/primitives/src/__tests__/anchor.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { buildIidAnchor, DEFAULT_URI_LIMITS } from '../anchor.js'; +import { recognizeAtomData } from '../validate.js'; + +describe('buildIidAnchor', () => { + it('golden P0 ISRC: exact UTF-8 bytes and atom ID', () => { + const result = buildIidAnchor('music-recording', { isrc: 'US-RC1-76-07839' }); + + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.value.iid).toBe('int:isrc:USRC17607839'); + expect(result.value.profile).toBe('p0'); + expect(result.value.data).toBe('int:isrc:USRC17607839'); + expect(result.value.dataHex).toBe('0x696e743a697372633a555352433137363037383339'); + expect(result.value.id).toBe( + '0xd3368a8190d3afd5fb05abd01db8141c09a77c3f290bb7a0ef2ffb2b5acab8d8' + ); + expect(result.value.classification).toBe('music-recording'); + expect(result.value.class).toBe('A'); + expect(result.value.providerPlan).toEqual(['musicbrainz', 'spotify', 'apple-music']); + expect(recognizeAtomData(result.value.data)).toBe('iid-anchor'); + }); + + it('Class C identities cannot emit bare P0', () => { + const values = { givenName: 'Ada', familyName: 'Lovelace' }; + + const defaulted = buildIidAnchor('person', values); + expect(defaulted.success).toBe(true); + if (defaulted.success) { + expect(defaulted.value.profile).toBe('p1'); // floors, never bare + expect(defaulted.value.class).toBe('C'); + const parsed = JSON.parse(defaulted.value.data); + expect(parsed['@type']).toBe('Person'); + expect(parsed.identifier).toBe(defaulted.value.iid); + expect(parsed.givenName).toBe('Ada'); // preimage evidence travels + expect(parsed.familyName).toBe('Lovelace'); + } + + const forced = buildIidAnchor('person', values, { profile: 'p0' }); + expect(forced.success).toBe(false); + if (!forced.success) { + expect(forced.errors[0]).toMatch(/cannot mint as a bare P0 anchor/); + } + }); + + it('polymorphic schemes cannot emit bare P0', () => { + // movie ladder tops out at wd via sameAs — polymorphic. + const result = buildIidAnchor( + 'movie', + { sameAs: ['https://www.wikidata.org/wiki/Q25188'] }, + { profile: 'p0' } + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors[0]).toMatch(/polymorphic/); + } + }); + + it('URI context never changes the atom ID', () => { + const bare = buildIidAnchor('music-recording', { isrc: 'USRC17607839' }); + const withUris = buildIidAnchor( + 'music-recording', + { isrc: 'USRC17607839' }, + { contextUris: ['https://app.example.com/track/1', 'ipfs://bafy123'] } + ); + + expect(bare.success && withUris.success).toBe(true); + if (!bare.success || !withUris.success) return; + + expect(withUris.value.id).toBe(bare.value.id); + expect(withUris.value.data).toBe(bare.value.data); + expect(withUris.value.contextUris).toEqual([ + 'https://app.example.com/track/1', + 'ipfs://bafy123', + ]); + }); + + it('URI manifest is ordered, trimmed, and exact-deduplicated', () => { + const result = buildIidAnchor( + 'music-recording', + { isrc: 'USRC17607839' }, + { + contextUris: [' https://a.example/1 ', 'https://b.example/2', 'https://a.example/1', ''], + } + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.value.contextUris).toEqual(['https://a.example/1', 'https://b.example/2']); + } + }); + + it('URI policy limits reject before any wallet interaction', () => { + const tooMany = buildIidAnchor( + 'music-recording', + { isrc: 'USRC17607839' }, + { contextUris: ['u://1', 'u://2', 'u://3', 'u://4', 'u://5', 'u://6'] } + ); + expect(tooMany.success).toBe(false); + if (!tooMany.success) { + expect(tooMany.errors[0]).toMatch(/limit is 5/); + } + + const tooLong = buildIidAnchor( + 'music-recording', + { isrc: 'USRC17607839' }, + { contextUris: [`https://example.com/${'a'.repeat(700)}`] } + ); + expect(tooLong.success).toBe(false); + + const liveLimits = buildIidAnchor( + 'music-recording', + { isrc: 'USRC17607839' }, + { contextUris: ['u://1', 'u://2'], uriLimits: { maxUris: 1, maxUriBytes: 64 } } + ); + expect(liveLimits.success).toBe(false); + + expect(DEFAULT_URI_LIMITS).toEqual({ maxUris: 5, maxUriBytes: 700 }); + }); + + it('reordered equivalent sameAs input produces deterministic output', () => { + const forward = buildIidAnchor('movie', { + sameAs: ['https://www.wikidata.org/wiki/Q25188', 'https://www.wikidata.org/wiki/Q42'], + }); + const reversed = buildIidAnchor('movie', { + sameAs: ['https://www.wikidata.org/wiki/Q42', 'https://www.wikidata.org/wiki/Q25188'], + }); + + expect(forward.success && reversed.success).toBe(true); + if (forward.success && reversed.success) { + expect(forward.value.iid).toBe(reversed.value.iid); + expect(forward.value.data).toBe(reversed.value.data); + expect(forward.value.id).toBe(reversed.value.id); + } + }); + + it('gen1 P1 payload has frozen key order and deterministic bytes', () => { + const first = buildIidAnchor('person', { givenName: 'Ada', familyName: 'Lovelace' }); + const second = buildIidAnchor('person', { familyName: 'Lovelace', givenName: 'Ada' }); + + expect(first.success && second.success).toBe(true); + if (first.success && second.success) { + expect(first.value.data).toBe(second.value.data); + expect(first.value.id).toBe(second.value.id); + expect(first.value.data.indexOf('"@context"')).toBeLessThan( + first.value.data.indexOf('"@type"') + ); + expect(first.value.data.indexOf('"@type"')).toBeLessThan( + first.value.data.indexOf('"identifier"') + ); + } + }); + + it('unknown classification and unfired ladders are structured errors', () => { + expect(buildIidAnchor('not-a-thing', {}).success).toBe(false); + + const noRung = buildIidAnchor('music-recording', {}); + expect(noRung.success).toBe(false); + if (!noRung.success) { + expect(noRung.errors[0]).toMatch(/No identity rung fired/); + } + }); +}); + +describe('recognizeAtomData', () => { + it('recognizes canonical IID anchors without reclassifying arbitrary strings', () => { + expect(recognizeAtomData('int:isrc:USRC17607839')).toBe('iid-anchor'); + expect(recognizeAtomData('int:isbn:0-684-83272-0')).toBe('invalid'); // non-canonical + expect(recognizeAtomData('int:src:whatever')).toBe('invalid'); // unknown scheme + expect(recognizeAtomData('{"@type":"Person"}')).toBe('json-object'); + expect(recognizeAtomData('42')).toBe('invalid'); + expect(recognizeAtomData('not json')).toBe('invalid'); + }); +}); diff --git a/packages/primitives/src/anchor.ts b/packages/primitives/src/anchor.ts new file mode 100644 index 0000000..319f9c3 --- /dev/null +++ b/packages/primitives/src/anchor.ts @@ -0,0 +1,229 @@ +import { getClassification, identityLadderFor } from '@0xintuition/classifications'; +import { calculateAtomId } from '@0xintuition/ids'; +import type { IdentityLadder, IntuitionId } from '@0xintuition/iid'; +import { deriveIntuitionId, inspectIntuitionId } from '@0xintuition/iid'; +import { classificationForIid, providersForIid } from '@0xintuition/iid-registry'; + +import type { AtomAnchor, BuildResult, IidAnchorOptions, UriLimits } from './types.js'; + +/** + * Offline policy defaults for the URI context manifest. These mirror the + * protocol's effective defaults but are a POLICY floor, not a substitute + * for the live contract configuration — callers that know the current + * on-chain `getAtomUriConfig` values pass them via `options.uriLimits`. + */ +export const DEFAULT_URI_LIMITS: UriLimits = Object.freeze({ + maxUris: 5, + maxUriBytes: 700, +}); + +const encoder = new TextEncoder(); + +function toHex(data: string): `0x${string}` { + let hex = '0x'; + + for (const byte of encoder.encode(data)) { + hex += byte.toString(16).padStart(2, '0'); + } + + return hex as `0x${string}`; +} + +/** + * Normalize the URI context manifest: trim entries, drop empties, remove + * exact duplicates, PRESERVE first-occurrence order. URI context never + * changes the IID, the atom bytes, or the atom ID — it travels alongside + * creation and is emitted, not stored. + */ +function normalizeContextUris( + contextUris: readonly string[], + limits: UriLimits, + errors: string[] +): readonly string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const uri of contextUris) { + const trimmed = uri.trim(); + + if (trimmed.length === 0 || seen.has(trimmed)) { + continue; + } + + const byteLength = encoder.encode(trimmed).length; + + if (byteLength > limits.maxUriBytes) { + errors.push( + `Context URI exceeds ${limits.maxUriBytes} bytes (${byteLength}): "${trimmed.slice(0, 64)}…"` + ); + continue; + } + + seen.add(trimmed); + normalized.push(trimmed); + } + + if (normalized.length > limits.maxUris) { + errors.push(`Context manifest has ${normalized.length} URIs; the limit is ${limits.maxUris}.`); + } + + return normalized; +} + +/** + * Build a canonical IID atom anchor: the supported high-level path from a + * classification's field values to on-chain-ready atom bytes. + * + * Interprets the classification's declarative identity ladder through the + * `@0xintuition/iid` engine, selects the representation profile, serializes + * canonical bytes, computes the atom ID through `@0xintuition/ids`, and + * returns the registry's classification and provider hints plus the + * normalized URI context manifest. + * + * Profiles (IID spec §7): + * - `p0` — atom data is the bare IID string. Legal only for anchor-eligible + * identifiers (Class A/B, unambiguously typed scheme). This is the only + * profile with protocol-level dedupe: `atomId = calculateAtomId(iid)`. + * - `p1` — a deterministic JSON object carrying `@context`, `@type`, + * `identifier`, and the identity fields the fired rung consumed. The + * REQUIRED floor for Class C identifiers and polymorphic schemes. + * + * The default profile is the strongest legal one: `p0` when eligible, + * otherwise `p1`. Requesting `p0` for an ineligible identifier is a + * structured error, never a silent downgrade. + * + * Pure and offline: no wallet calls, provider fetches, live config reads, + * or on-chain duplicate checks. + */ +export function buildIidAnchor( + classificationSlug: string, + values: Record, + options: IidAnchorOptions = {} +): BuildResult { + const spec = getClassification(classificationSlug); + + if (!spec) { + return { success: false, errors: [`Unknown classification "${classificationSlug}".`] }; + } + + const ladder = identityLadderFor(classificationSlug); + + if (!ladder) { + return { + success: false, + errors: [`Classification "${classificationSlug}" declares no identity ladder.`], + }; + } + + const derived = deriveIntuitionId(ladder, values); + + if (!derived) { + return { + success: false, + errors: [ + `No identity rung fired for "${classificationSlug}": the provided values contain neither a usable registry identifier nor a complete derivation recipe.`, + ], + }; + } + + const inspection = inspectIntuitionId(derived.iid); + + if (!inspection.valid) { + return { + success: false, + errors: [`Derived identifier failed inspection: ${derived.iid} (${inspection.reason}).`], + }; + } + + const requestedProfile = options.profile; + const profile = requestedProfile ?? (inspection.anchorEligible ? 'p0' : 'p1'); + + if (profile === 'p0' && !inspection.anchorEligible) { + const reason = + inspection.anchorIneligibilityReason === 'class-c' + ? 'Class C identifiers must carry their recipe fields in a P1 payload' + : 'polymorphic schemes must carry @type in a P1 payload'; + return { + success: false, + errors: [`${derived.iid} cannot mint as a bare P0 anchor: ${reason}.`], + }; + } + + const errors: string[] = []; + const contextUris = normalizeContextUris( + options.contextUris ?? [], + options.uriLimits ?? DEFAULT_URI_LIMITS, + errors + ); + + if (errors.length > 0) { + return { success: false, errors }; + } + + const data = + profile === 'p0' ? derived.iid : buildProfilePayload(spec.type, derived, ladder, values); + + const registryClassification = classificationForIid(derived.iid); + + return { + success: true, + value: { + classification: registryClassification?.slug ?? classificationSlug, + profile, + iid: derived.iid, + scheme: derived.scheme, + class: derived.class, + ...(derived.tag !== undefined ? { tag: derived.tag } : {}), + data, + dataHex: toHex(data), + id: calculateAtomId(data), + contextUris, + providerPlan: providersForIid(derived.iid), + values: Object.freeze({ ...values }), + }, + }; +} + +/** + * The deterministic P1/P2 payload. Key order is frozen: `@context`, + * `@type`, `identifier`, then identity fields sorted by key. For a fired + * gen1 rung the identity fields are the recipe's inputs as provided (the + * preimage evidence — spec §6.6); scheme rungs carry no extra fields at P1. + */ +function buildProfilePayload( + schemaType: string, + derived: { iid: IntuitionId; tag?: number }, + ladder: IdentityLadder, + values: Record +): string { + const identityFields: Record = {}; + + if (derived.tag !== undefined) { + const rung = ladder.rungs.find( + (candidate) => candidate.kind === 'gen1' && candidate.tag === derived.tag + ); + + if (rung && rung.kind === 'gen1') { + for (const field of rung.recipe) { + const sourceKey = 'of' in field ? field.of : field.key; + const provided = values[sourceKey] ?? values[field.key]; + + if (provided !== undefined && provided !== null && provided !== '') { + identityFields[field.key] = provided; + } + } + } + } + + const payload: Record = { + '@context': 'https://schema.org/', + '@type': schemaType, + identifier: derived.iid, + }; + + for (const key of Object.keys(identityFields).sort()) { + payload[key] = identityFields[key]; + } + + return JSON.stringify(payload); +} diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7769be6..c75ad30 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -41,6 +41,8 @@ export { shortenHex, TRIPLE_SALT, } from '@0xintuition/ids'; +// Predicate helpers +export { buildIidAnchor, DEFAULT_URI_LIMITS } from './anchor.js'; // Atom builders export { buildAggregateRating, @@ -89,21 +91,23 @@ export { listClassifications, suggestClassification, } from './discover.js'; - -// Predicate helpers export { buildCustomPredicate, getPredicateInfo, listPredicates } from './predicate.js'; // Triple builders export { buildCounterTriple, buildTriple, buildTripleByName } from './triple.js'; // Types export type { + AnchorProfile, + AtomAnchor, AtomBlueprint, BuildResult, ClassificationSummary, CounterTripleBlueprint, FieldInfo, + IidAnchorOptions, PredicateInfo, TripleBlueprint, + UriLimits, ValidationResult, } from './types.js'; // Validation helpers -export { isValidAtomData, validateAtom } from './validate.js'; +export { isValidAtomData, recognizeAtomData, validateAtom } from './validate.js'; diff --git a/packages/primitives/src/types.ts b/packages/primitives/src/types.ts index c48f204..e298012 100644 --- a/packages/primitives/src/types.ts +++ b/packages/primitives/src/types.ts @@ -51,6 +51,65 @@ export interface CounterTripleBlueprint { objectId: Hex; } +/** + * Representation profile of an IID atom (IID spec §7): `p0` bare anchor, + * `p1` identity context, `p2` enriched. + */ +export type AnchorProfile = 'p0' | 'p1' | 'p2'; + +/** Caller-supplied URI manifest limits (mirror `getAtomUriConfig` live values). */ +export interface UriLimits { + readonly maxUris: number; + readonly maxUriBytes: number; +} + +/** Options for the IID anchor builder. */ +export interface IidAnchorOptions { + /** + * Requested profile. Default: the strongest legal one (`p0` when the + * derived identifier is anchor-eligible, otherwise `p1`). Requesting + * `p0` for an ineligible identifier is a structured error. + */ + readonly profile?: AnchorProfile; + /** Ordered URI context manifest (deduplicated, order-preserving). */ + readonly contextUris?: readonly string[]; + /** Live protocol limits; defaults to the offline policy floor. */ + readonly uriLimits?: UriLimits; +} + +/** + * A canonical IID atom anchor: on-chain-ready atom bytes derived from a + * classification's identity ladder, plus read-side hints. + * + * URI context never changes `data`, `dataHex`, or `id`. + */ +export interface AtomAnchor { + /** Classification slug resolved for the identifier. */ + classification: string; + /** Representation profile the data was serialized at. */ + profile: AnchorProfile; + /** The derived canonical Intuition ID. */ + iid: string; + /** IID scheme that fired. */ + scheme: string; + /** Identity class of that scheme (A/B/C). */ + class: 'A' | 'B' | 'C'; + /** Stable gen1 rung tag when a derived rung fired. */ + tag?: number; + /** Exact atom data string (bare IID at p0; deterministic JSON at p1/p2). */ + data: string; + /** UTF-8 bytes of `data` as hex. */ + dataHex: Hex; + /** Deterministic atom ID over `data`. */ + id: Hex; + /** Normalized, ordered, deduplicated URI context manifest. */ + contextUris: readonly string[]; + /** Ordered enrichment provider capability plan for the identifier. */ + providerPlan: readonly string[]; + /** The original field values used to derive the identifier. */ + values: Readonly>; +} + /** * A result type that represents either a successful value or a list of errors. * Used throughout the builder API to avoid throwing on bad input. diff --git a/packages/primitives/src/validate.ts b/packages/primitives/src/validate.ts index 2d93913..a9620da 100644 --- a/packages/primitives/src/validate.ts +++ b/packages/primitives/src/validate.ts @@ -1,4 +1,5 @@ import { hasClassification, validateClassificationValues } from '@0xintuition/classifications'; +import { validateIntuitionId } from '@0xintuition/iid'; import type { ValidationResult } from './types.js'; @@ -72,11 +73,30 @@ export function validateAtom( * @returns `true` if the string is a valid JSON object, `false` otherwise. */ export function isValidAtomData(data: string): boolean { + return recognizeAtomData(data) !== 'invalid'; +} + +/** + * Classify an atom data string without reclassifying arbitrary strings: + * + * - `'iid-anchor'` — a VALID canonical IID (P0 anchor bytes). Only strings + * that pass full IID validation qualify; a well-formed but non-canonical + * or unknown-scheme string is NOT an anchor. + * - `'json-object'` — a JSON object (legacy JSON-LD and P1/P2 payloads). + * - `'invalid'` — anything else. + */ +export function recognizeAtomData(data: string): 'iid-anchor' | 'json-object' | 'invalid' { + if (validateIntuitionId(data)) { + return 'iid-anchor'; + } + try { const parsed: unknown = JSON.parse(data); - return !!parsed && typeof parsed === 'object' && !Array.isArray(parsed); + return !!parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? 'json-object' + : 'invalid'; } catch { - return false; + return 'invalid'; } }