PROJECT STATUS (2026-05-30): the core indexer is FEATURE-COMPLETE. The full
kasgraph build → deploy → index → queryloop is multi-tenant, hot-reloadable, and verified end-to-end against real Postgres. Every self-contained logic track is done — detection (17/17 real fingerprints), operation decode + liveCovenantSpentdispatch, POI over dispatched state, the GraphQL query layer (entity/spends/typed per-subgraph schemas + relations + first-class lineage DAG), the KIP-20 lineage fork model, and the deploy pipeline (registry → typed routing → CLI → HTTP--nodeendpoint with bearer-token deploy/remove auth → wasm data-plane → node consume → multi-subgraph fan-out → dynamic reload). Remaining before public production claims:logsstreaming, live testnet indexing soak, benchmark publication, CI confirmation, README/status claim audit, and kasgraph.com hosted deployment confirmation. SeeLAUNCH_CHECKLIST.mdfor the pre-public-launch gate.
Autonomous-work history (newest first). The original Phase 2.6 note is preserved below for context but is long-superseded by the arcs above it.
Latest commit arc (2026-05-30 — dynamic registry reload: a deploy is picked up without a node restart)
The runtime set was built once at startup; the node now periodically re-reads the registry and reconciles the live set in place, so kasgraph deploy/remove take effect on a running node.
reconcile_subgraph_runtimes(store, runtimes: &mut Vec<SubgraphRuntime>, work_dir)— diffs the live set againstlist_subgraph_deployments(): builds + adds a runtime for each newly-deployed subgraph, keeps survivors with their in-flight ingestion + POI state, drops runtimes whose subgraph was removed. The fallible loads (materialize + compile) happen before any mutation, so a DB/load error leaves the current set intact; a transiently-empty registry read is a no-op (never tears down — also protects the single-configured fallback runtime). Returns aReloadDelta { added, removed }. Sharedload_deployment_mappinghelper (used by build + reconcile).- Wiring:
run_continuous_ingestionnow takes&mut Vec<SubgraphRuntime>+work_dir; atokio::time::interval(KASGRAPH_RELOAD_INTERVAL_SECS, default 0 = disabled) fires a reconcile branch in theselect!(skips notification processing for that tick). Fan-out calls reborrow viaas_mut_slice(). - Tests: node integration
reconcile_adds_new_drops_removed_and_preserves_survivor_state— start with sg_a (distinctive POI), deploy sg_a+sg_b → reconcile adds sg_b + preserves sg_a's POI; no-change reconcile is a no-op; remove sg_b → reconcile drops it. node integration 72/72; default workspace + vitest green; node clippy clean. - The indexer is feature-complete. build→deploy→index→query is multi-tenant and now hot-reloadable. Remaining Phase 5 is operational/infra only:
logsstreaming + auth (hosted-service surface, needs a topology/identity steer) — both are genuinely outside the self-contained-logic envelope. No core-logic gaps remain across detection, decode/dispatch, POI, query (entity/spends/typed/relations/lineage-DAG), registry, typed-routing, CLI, deploy (control + data plane), node consume, fan-out, and reload.
The node ran one mapping (KASGRAPH_SUBGRAPH); it now indexes every deployed subgraph concurrently over the single shared chain subscription. With the registry as source of truth, one deploy makes the node pick the subgraph up (alongside the others) on its next start.
SubgraphRuntime { subgraph, ingestion, mapping }— one per indexed subgraph: its own block-DAG ingestion view + POI chain + loaded mapping, persisting into its own per-subgraph schema.build_subgraph_runtimes(store, configured, work_dir)assembles the set: every activelist_subgraph_deployments()row (mapping materialized fromwasm_bytes), or — if the registry is empty — the single configured subgraph (mapping from registry orKASGRAPH_SUBGRAPH_DIR, the legacy/dev path). Each runtime re-anchors its POI from its own surviving checkpoint (make_subgraph_runtime).fan_out_notification(store, runtimes, notification, stream)— applies oneChainNotification(nowCloned) to every runtime via the existingapply_and_persist_notification; returns the first runtime's outcome as representative (the block-DAG transition is identical across runtimes, so recovery decisions converge).run_continuous_ingestion+ the bootstrap arm now take&mut [SubgraphRuntime]and fan out at each notification + the recovery point; the recovery anchor readsruntimes[0].- Tests: node integration
fan_out_dispatches_a_block_to_every_subgraph_runtime— two runtimes load the same emitting mapping, one committed block fans out, and both subgraphs independently persist the dispatched entity + a POI checkpoint into their own schemas. node integration 71/71; default workspace + vitest green; node clippy clean (remaining warnings are pre-existing in kasgraph-mapping/stream). - The build→deploy→index→query pipeline is now complete and multi-tenant: deploy N subgraphs → the node loads + indexes all N from the registry → each is queryable typed. Remaining Phase 5 is operational only:
logsstreaming + auth (hosted-service surface), and a live-restart signal so adeployis picked up without a node restart (today the runtime set is built once at startup; re-reading the registry on a signal/interval is the dynamic-reload follow-up). No core-logic gaps remain.
Previous commit arc (2026-05-30 — node consumes the registry: deploy → the node loads + runs the mapping)
Closes the deploy loop end-to-end. The producer side persisted the wasm bytes + metadata; the node now reads them at startup, materializes a build dir, and LoadedMapping::loads it — so kasgraph deploy actually makes the node index the subgraph, no on-disk build or file copy required.
- Store:
SubgraphDeployment.wasm_bytes: Option<Vec<u8>>threaded throughupsert/fetch/list+ theupsert_subgraph_deployment_sqlbuilder (columnwasm_bytes,ON CONFLICToverwrite). Added aSubgraphDeploymentRowtype alias (satisfiestype_complexityon the 5-col row). Integration test round-trips the bytes (+ a redeploy with different bytes). - Node:
mapping_host::materialize_subgraph_dir(deployment, base)writes<base>/<id>/build/manifest.json(the stored descriptor) + the wasm it names (fromwasm_bytes) +schema.graphql, returning a dir ready forLoadedMapping::load.main::load_mapping_from_registry(store, subgraph, base)fetches the deployment and materializes+loads it (Noneif undeployed or metadata-only). Startup now prefers the registry overKASGRAPH_SUBGRAPH_DIRover none (working dir fromKASGRAPH_WORK_DIR, default tmp). - Tests: node integration
materializes_and_loads_a_mapping_deployed_to_the_registry(upsert a deployment with WAT-as-wasm bytes → materialize → load → dispatch a hit, asserting the registry row alone runs the mapping). store integration 10/10, node integration 70/70, default workspace + vitest green; store clippy clean. - Migration-count test → 9 (last arc); store struct change is additive. The deploy loop is now complete:
kasgraph build→deploy(control + data plane) → the node loads the mapping from the registry + dispatches → typed queries served. Remaining Phase 5 is operational polish:logsstreaming, auth, and loading multiple subgraphs fromlist_subgraph_deployments(today the node runs the oneKASGRAPH_SUBGRAPHit's configured for; the multi-subgraph fan-out is a node-runtime change). The core build→deploy→index→query pipeline is feature-complete + tested end-to-end against real Postgres.
Previous commit arc (2026-05-30 — deploy data-plane: wasm bytes travel with the deploy + persist in the registry)
The deploy hand-off's data plane. The control plane recorded SDL + manifest + wasm hash; now the wasm bytes travel with the deploy and persist, so a node has everything it needs to run a mapping with no separate file transfer. Decision (reversible): bytes live in the registry as Postgres BYTEA — the registry is already the cross-process source of truth both the TS gateway and Rust node share, so this assumes no shared volume / object store / co-location; swappable for an object-store pointer later if mappings grow large.
- Migration
20260530160000_subgraph_wasm_bytes.sql— nullablewasm_bytes BYTEAonkasgraph_subgraph. (Rust migration-count test → 9; store struct/upsert untouched — column nullable, so existing Rust writes bind NULL.) - TS deploy (producer):
DeployBundle.wasmBase64(required) —assembleDeployBundlereads the wasm → base64;DeploySubgraphInput.wasmBase64(optional) on the endpoint.parseDeployBundleintegrity-checks the bytes againstwasmSha256when both present (sha mismatch → 400). Both writers (PgSubgraphRegistrydirect path + the HTTP endpoint'sdeploySubgraph) decode base64 →Bufferand bind it towasm_bytes. The HTTP transport already POSTs the whole bundle, so--nodecarries the bytes for free. - Tests: assembleDeployBundle asserts
wasmBase64(\0asm→AGFzbQ==); PgSubgraphRegistry/endpoint upsert bind aBufferatwasm_bytes; parseDeployBundle accepts bytes + rejects a sha mismatch; e2e (real PG) assertsoctet_length(wasm_bytes) = 4after deploy. default vitest 238 | 1 skipped; e2e + Rust store integration 10/10 vs Postgres; typecheck clean. - Remaining Phase 5: the node-side consume half —
kasgraph-node(Rust) reads its active subgraph set from the registry at startup, materializes eachwasm_bytes(+ manifest) to a dir, andLoadedMappings it — so adeployactually makes the node index the subgraph. (Pluslogsstreaming + auth.) The deploy producer side (CLI → endpoint → registry, bytes + metadata) is now complete + tested end-to-end.
Previous commit arc (2026-05-30 — Phase 5 begins: hosted-node deploy HTTP endpoint + deploy --node <url>)
The first Phase 5 slice — both ends, fully tested. kasgraph deploy could write the registry directly (--database-url); now it can also target a hosted node over HTTP (--node <url>), and the gateway binary serves that endpoint.
- Server (
@kasgraph/api/deploy-endpoint.ts) —handleDeployRequest({method, path, body}, pool)is a pure router (no socket/framework):POST /subgraphs(validate+upsert the bundle → 200{subgraphId}| 400),GET /subgraphs/:id(→ 200 | 404),DELETE /subgraphs/:id(soft-remove → 200 | 404), 404/400/405 otherwise.parseDeployBundlevalidates the bundle (id^[a-z0-9_]+$, sdl, manifest, optional wasm hash).deploySubgraph/removeSubgraphare the writers (same SQL as the store/CLI).createDeployFetchHandler(pool)is a Fetch adapter. - Operator mount (
main.ts) —nodeDeployHandler(pool)(Node-http: reads body →handleDeployRequest→ JSON response);createKasGraphHttpHandler(yoga, healthCheck, deployHandler?)gained an optional deploy handler and routes/subgraphs*to it before healthz/Yoga (backward compatible — omitted ⇒ falls through to Yoga).runKasGraphServerwiresnodeDeployHandler(pool), so the gateway binary now serves deploy + GraphQL + healthz on one port. - CLI (
HttpDeployTransport) — implementsSubgraphRegistryClientoverfetch(POST/GET/DELETE/subgraphs), injectableFetchLikefor tests.runDeploy/status/remove's default factory now prefers--node/KASGRAPH_NODE_URL(HTTP) over--database-url/DATABASE_URL(direct pg). - Tests:
tests/deploy-endpoint.test.ts(parseDeployBundle + handleDeployRequest POST/GET/DELETE/404/400 vs a mock pool);cli-deploy.test.ts(+HttpDeployTransport POST/DELETE/GET mapping incl. non-2xx → throw, +resolveNodeUrl, +runDeploy --noderoutes through a stubbed globalfetch);main.test.ts(+/subgraphsroutes to the deploy handler over a real bound socket, +falls through to Yoga when unwired). vitest 236 | 1 skipped; typecheck clean. No Rust change. - Remaining Phase 5: the deploy→node wasm hand-off (the endpoint records the SDL/manifest/hash; the wasm bytes still need to reach the node's disk for
LoadedMapping),logsstreaming, auth, and the node loading its active subgraph set from the registry. The deploy control-plane round-trip (CLI ⇄ node ⇄ registry ⇄ query) is now complete and tested.
Previous commit arc (2026-05-30 — first-class lineage DAG: utxo + childUtxos on CovenantLineageEntry)
Surfaces the fork structure the parent_utxo edge enabled as an explicit DAG in the GraphQL covenantLineage response, in both directions, with no extra query.
CovenantLineageEntry(@kasgraph/apiinterface + SDL) gainsutxo: String!(the entry's owntxHash:outputIndex— the value a child'sparentUtxopoints at) andchildUtxos: [String!]!(the forward edges — the UTXOs of entries whoseparentUtxois this one; empty for a tip, length > 1 where a transfer forked).parentUtxo(added earlier) is the backward edge.- Derived in-resolver, query-free:
buildChildEdges(rows)maps eachparent_utxo→ child UTXOs from the already-loaded lineage rows;rowToLineageEntry(row, childEdges)fillsutxo/childUtxos. So a singlecovenantLineageresponse carries the whole DAG (genesis = the entry with noparentUtxo; tips = entries with emptychildUtxos) without a second round-trip. - Tests: pg-resolvers — extended the existing chain test (asserts
utxo+childUtxos) + a new fork test (one parent → two children: genesischildUtxos = ['xfer:0','xfer:1'], both children tips); api.test — the in-memory fixture + query updated to select/assertutxo/parentUtxo/childUtxos. vitest 221 | 1 skipped; typecheck clean. No Rust change. - State unchanged otherwise: all self-contained logic tracks DONE; remaining is Phase 5 hosted infra. The lineage DAG is now fully first-class server-side, so that polish item is closed too. Remaining logic-side polish: node-loads-subgraphs-from-registry at startup (partly infra-coupled via the wasm path); the LLM/address-indexed MCP tools (externally blocked).
Previous commit arc (2026-05-30 — list_subgraphs is registry-sourced: a deployed subgraph shows before it indexes a block)
Completes the registry's role as the source of truth across the whole MCP surface (list / get_schema / execute_query now all registry-aware).
PgMcpHandlers.list_subgraphsnow lists active rows fromkasgraph_subgraph(was: derived fromkasgraph_committed_block, so a just-deployed subgraph with no indexed blocks yet was invisible).blocks_indexedbecomes enrichment via aLEFT JOINto the committed-block counts (0 until ingestion catches up); the displaynamecomes from the deployed manifest (manifest_json->>'name', falling back to the id);status='removed'rows are hidden; the keywordLIKEfilter is preserved.- Tests: the 3
list_subgraphsunit tests updated to the registry-sourced query (incl. the null-display-name → id fallback and the never-indexed → 0 case); the e2e test extended to assertlist_subgraphsincludes the subgraph after deploy (withblocks_indexed: 0) and excludes it afterremove. Default vitest 220 | 1 skipped; e2e passes vs the live container; typecheck clean. No Rust change. - State unchanged otherwise: all self-contained logic tracks DONE; remaining is Phase 5 hosted infra. Smaller logic-side polish still open: load the node's active subgraph set from the registry at startup; a richer GraphQL
lineage/successorshape; the LLM/address-indexed MCP tools.
Proves the last three arcs (registry + typed-schema routing + deploy CLI) actually compose against a live DB, not just under mock pools — the first real-Postgres TS test.
tests/e2e-deploy-query.test.ts— drives the real code paths end to end:PgSubgraphRegistry.upsertDeployment(the CLI's deploy write) →PgMcpHandlers.get_schema/execute_query(the gateway/MCP read) →fetchSubgraphDeployment→executeSubgraphQuery(typed query over"<id>".entity_versions) →PgSubgraphRegistry.setRemoved. Asserts: before deployget_schemareturns the base meta schema; after deploy it returns the subgraph's own typed schema (bond(id: ID!): Bond, noCommittedBlock); a typed{ bond(id:"b1"){ id owner } }returns the seeded entity; afterremovethe same query falls back to the base gateway and errors (nobondfield) — i.e. removal stops serving it.- Gating mirrors the Rust
integration-pgfeature: the suite runs only whenDATABASE_URLis set, elsedescribe.skip. Defaultnpx vitest run→ 220 passed | 1 skipped (CI stays green, no DB needed). WithDATABASE_URL=…→ the e2e test passes against the live container. Setup is self-contained + idempotent (createskasgraph_subgraph+ a throwaway"e2e_<rand>"schema/entity_versions, drops them in teardown) so it needs no pre-migrated DB and never collides. - typecheck clean; no Rust change. Run:
DATABASE_URL=postgres://kasgraph:kasgraph@127.0.0.1:5434/kasgraph npx vitest run e2e-deploy-query. - State of the project: every self-contained logic track is DONE and the build→deploy→query loop is now verified end-to-end against a real DB. Remaining is Phase 5 hosted infra (a hosted-node HTTP service so
deploy --node <url>targets a remote node + auth/packaging;logsstreaming; the deploy→node wasm hand-off) — an ops/infra build, not a self-contained logic slice. Smaller logic-side polish if continuing: load the node's active subgraph set from the registry (list_subgraph_deployments) at startup instead of static dir config; surfaceparentUtxo/the lineage DAG in a richer GraphQLlineage/successorshape;query_natural_language/ address-activity MCP tools (need an LLM hook / address-indexed view).
Previous commit arc (2026-05-30 — kasgraph deploy / status / remove CLI: a subgraph is now deployable end-to-end)
The last command-surface gap. deploy/status/remove were "not implemented yet" stubs; they now write/read the kasgraph_subgraph registry — the exact row the gateway/MCP read (last two arcs) to serve a subgraph's typed schema. So the full loop is closed: build → deploy → the subgraph is queryable typed via MCP execute_query/get_schema.
cli/src/deploy.ts—assembleDeployBundle(root)(pure FS: readsbuild/manifest.json+ the wasm it names +schema.graphql, derives the SubgraphId viasubgraphIdFromName— sanitizes a display name likenetwork-stats→network_statsto the store's^[a-z0-9_]+$, hashes the wasm; returns{error, code}not throws).PgSubgraphRegistry(against a minimal localDeployPool) writes the same upsert SQL as the store'supsert_subgraph_deployment(ON CONFLICT (subgraph)overwrites + reactivates),setRemoved(soft-delete via... RETURNING subgraph),fetchStatus.runDeploy/runStatus/runRemovetake an injectedRegistryClientFactory(default: lazy-pgpool from--database-url/DATABASE_URL) so tests use a capturing fake. The wasm bytes stay on disk (the node'sLoadedMappingloads them); the registry carries the hash for provenance.- Dispatch:
deploy/status/removewired inrunCommand; onlylogsremains a stub (needs the hosted-node log stream — Phase 5).pgadded toclideps. - Tests (
tests/cli-deploy.test.ts, 16):subgraphIdFromName(passthrough/sanitize/null);assembleDeployBundle(success + not-built → 66 + missing-wasm-file → 66 + missing-schema);PgSubgraphRegistryagainst a mock pool (upsert bind shape +ON CONFLICT;setRemovedtrue/false via RETURNING;fetchStatusmap/null);runDeploy(success upserts + closes + reports / no-client → 78 / not-built → 66 before touching registry);runStatus/runRemove(found/missing/usage);resolveDatabaseUrl. Updated the oldcli.test.ts"not implemented" test to cover onlylogs. vitest 220; typecheck clean. No Rust change. - Phase 4 CLI surface is complete (init/codegen/build/deploy/status/remove/mcp-config; only
logspending infra). Remaining = Phase 5 hosted infrastructure: a hosted-node HTTP service (sodeploy --node <url>targets a remote node instead of a direct DB write, pluslogsstreaming), packaging/auth, and the deploy→node wasm hand-off. That's an infra/ops track rather than a self-contained logic slice — the indexer, detection, query, and deploy logic are all now in place and tested. A good in-repo follow-up if staying logic-side: an end-to-end test thatdeploys a built example subgraph to a live Postgres (PgSubgraphRegistry) and then queries it typed throughexecuteSubgraphQuery, proving build→deploy→query against a real DB.
Previous commit arc (2026-05-30 — typed-schema query routing: MCP execute_query/get_schema serve a deployed subgraph's OWN schema)
Closes the typed per-subgraph API loop end to end: a deployed subgraph's schema.graphql (now persisted in kasgraph_subgraph) is read at query time and used to serve that subgraph's own typed entity schema — the feature was "logically complete" but unreachable because nothing routed to it.
fetchSubgraphDeployment(pool, subgraph)(@kasgraph/api, exported) — readsschema_sdl/manifest_json/wasm_sha256fromkasgraph_subgraph(skipsstatus='removed';subgraphis a bound param, no interpolation), returningSubgraphDeploymentInfo | null.PgMcpHandlers.get_schema— a deployed subgraph returnsbuildSubgraphSchemaSdl(its SDL)(its typed entity queries:bond(id),bonds(first), …); an undeployed id returns the canonicalKASGRAPH_BASE_SCHEMA_SDL.PgMcpHandlers.execute_query— routing decision: a known deployed subgraph's query runs against its own typed schema (entity types over"<id>".entity_versions, viaexecuteSubgraphQuery); an undeployed id falls back to the base meta gateway (cross-subgraph queries likecommittedBlocks/covenantLineage, which also have dedicated MCP tools). Backward compatible — undeployed ids behave exactly as before, just preceded by one cheap registry lookup. MCPget_covenant_lineage/search_by_pattern/list_subgraphsremain the meta surface.- Rationale for the split: the base gateway's queries take
subgraphas an arg (cross-subgraph meta), while a deployed subgraph's typed schema serves its own entity types — two distinct schemas. "Deployed → typed, else → meta" is the clean routing; a deployed subgraph's meta queries go through the dedicated tools. - Tests: +2 pg-handlers tests (get_schema returns typed SDL for a deployed subgraph; execute_query routes a deployed query to
"<id>".entity_versions), and the existing get_schema/execute_query/parse-error tests updated for the leading registry lookup. vitest 204; typecheck clean. No Rust change. - Remaining (1 track):
kasgraph deploy/status/removeCLI —deploypackages the built manifest + wasm + schema.graphql and writes a registry row (storeupsert_subgraph_deployment);status/removeread / soft-delete it. The store + gateway sides are now both in place, so this is the last piece before a subgraph is deployable + queryable end to end. (Hosted-node HTTP transport for the CLI→node call is the remaining Phase 5 infra; the write can target the store directly or a mock transport until then.)
Previous commit arc (2026-05-30 — deployed-subgraph registry (store foundation for deploy + typed-schema routing))
The first concrete step of the deploy pipeline, and the foundation both remaining tracks need: persist each deployed subgraph's identity. ensure_subgraph_schema created a subgraph's data tables but nothing recorded its schema.graphql SDL or manifest — so the typed GraphQL surface had no SDL to build a schema from at query time, and status/remove had nowhere to record state.
- Migration
20260530140000_subgraph_registry.sql— a globalkasgraph_subgraphtable:subgraph(PK = SubgraphId / schema name),schema_sdl TEXT,manifest_json JSONB,wasm_sha256 TEXT?,status TEXT DEFAULT 'active',deployed_at TIMESTAMPTZ DEFAULT NOW()(+ a status index). - Store:
SubgraphDeployment { subgraph, schema_sdl, manifest_json, wasm_sha256 }+upsert_subgraph_deployment(redeploy overwrites in place, refreshesdeployed_at, reactivates a removed one),fetch_subgraph_deployment/list_subgraph_deployments(both skipstatus='removed'),set_subgraph_removed(soft delete — keeps the data schema + history for audit).upsert_subgraph_deployment_sql()is a&'static strbuilder (global table, no schema interpolation). - Tests: SQL-builder unit test (column list +
ON CONFLICT+ reactivation) + the migration-count test bumped to 8; a real-Postgres integration test (subgraph_registry_deploy_fetch_redeploy_remove) covering deploy → fetch round-trip (SDL + manifest JSON + wasm hash verbatim) → redeploy-overwrites → list → soft-remove hides from fetch/list → redeploy reactivates. store lib 21, store integration 10/10 (incl. the new one) vs Postgres; default workspace + vitest 202; typecheck/fmt/clippy clean. - Next (this directly unblocks both): (1) typed-schema routing — read
schema_sdlfromkasgraph_subgraphin the gateway and routeexecute_query(subgraph,…)/get_schemathroughexecuteSubgraphQuery/buildSubgraphSchemaSdl(a TSpg-resolversread + wiring; no infra). (2) deploy/status/remove CLI —kasgraph deploypackages built manifest+wasm+schema.graphql and writes a registry row (upsert_subgraph_deployment);status/removeread/soft-delete it. The CLI→node write path can be exercised against the store directly (or a mock transport) until the hosted-node HTTP endpoint exists.
Fixes the long-flagged persist_lineage fork problem. Under the adopted per-UTXO model covenant_id names the whole token, so a transfer forks it: one spent receipt → ≥2 same-id outputs. The single-head model recorded only a linear seq, so parallel branches looked like one chain. The honest, additive fix: record the lineage edge.
- Migration
20260530120000_covenant_lineage_parent_utxo.sql— adds a nullableparent_utxo TEXTtokasgraph_covenant_lineage_row(the spent predecessortx:output, NULL for genesis) + a(covenant_id, parent_utxo)index. Two outputs of one transfer now share the sameparent_utxo, so the branch (a DAG, not a chain) is faithful;seqis demoted to a per-covenant observation ordinal. - Store:
CovenantLineageRowgainsparent_utxo: Option<String>;insert_covenant_lineage_rowwrites it; newcovenant_lineage_rows_by_parent(covenant_id, parent_utxo)— the forward-edge (successor/children) lookup the column enables, ordered by seq. - Node:
LineageClass::Transition { covenant_id, parent_utxo }(was a tuple) carries the spent predecessor;classify_lineagefills it from the matched input (prev_tx:prev_idx);persist_lineagerecords it (genesis →None). - API/GraphQL:
CovenantLineageEntry.parentUtxo: Stringexposed (SDL + interface +rowToLineageEntry+ the entriesSELECT), so clients can reconstruct the branch tree. - Verified vs real Postgres: store integration 9/9 (incl. a new fork assertion — spending one UTXO into two same-id outputs yields two rows sharing
parent_utxo, returned bycovenant_lineage_rows_by_parent; migration applies; reorg unwind intact) and node integration 69/69 (the live spend/lock/POI dispatch flow unaffected). Default workspace + vitest 202 green; typecheck/fmt/clippy clean (added aLineageRowColumnstype alias to satisfytype_complexityon the 8-col fetch; bumped the migration-count unit test to 7). - Scope note (intentional):
current_utxo/lineage_counton the head stay as "latest observed tip / count of observed transitions" — useful aggregates, now honest given the edge carries the real structure. A full multi-tip head model wasn't needed to remove the false-linearization bug. - Remaining strategic tracks (now 2): (1) typed-schema deploy-time SDL storage — store each subgraph's
schema.graphqlat deploy + cache + routeexecute_query/get_schemathroughexecuteSubgraphQuery/buildSubgraphSchemaSdl; (2)deploy/status/logs/removeCLI + Phase 5 hosted infra. Both couple to the deploy pipeline — the natural next front is the deploy/CLI surface itself.
Previous commit arc (2026-05-30 — 🎯 registry COMPLETE: 4 KCC20 controller anchored fingerprints, 0 placeholders left)
Captured the last 4 placeholders — the KCC20 controller family — closing out the entire detector registry. All 17 entries are now real silverc-captured fingerprints (12 exact + 5 anchored); the 0xFE placeholder scheme is deleted.
- 4 new anchored
AnchoredFingerprints:kcc20_ownable_controller(admin/has_pending_admin/pending_admin/kcc20_covenant_id/initialized),kcc20_pausable_controller(kcc20_covenant_id/paused/initialized — noteadminis a ctor param but not stored state),kcc20_capped_controller(kcc20_covenant_id/total_cap/remaining_allowance/initialized),kcc20_vesting_controller(total_allocation/minted_amount/cliff_time/period/release_per_period/kcc20_covenant_id/initialized). Field layouts taken from the state declaration order (authoritative — differs from ctor order; the SDK'sbuildKcc20ControllerConstructorArgsconfirmed the param mapping). - Why anchored, not exact: the controllers embed a per-deployment, variable-length template (the controlled asset covenant's
templatePrefix/templateSuffix+expectedTemplateHash) into the redeem script, so script length + middle bytes vary across deployments — byte-exact can't generalize. Captured like KCC20Asset: head = LCP / tail = LCS of 3 compiles sharing identical state but differing template content AND length (so the per-deployment template is excluded from both anchors), state windows masked in the head via the push-prefix rule. Verified against a 4th, unseen instance with different state AND a template never used in capture — it matches and the state extracts correctly, proving template-independence. All 4 self-checks (windows-in-head, head+tail≤minlen, last-window-end == state_layout end, unseen match+extract) passed on the first run. - Registry: 4 placeholder entries →
PatternMatcher::Anchored(...). Deleted the now-deadopensilver()placeholder builder +field_label()+ the placeholder-discriminator test (replaced with ano_placeholder_fingerprints_remainguard). Module/header docs updated;detector-schema.tsregenerated. Cross-collision test passes across all 12 exact + 5 anchored real fingerprints. - detectors lib 140 (+12 = 4×3); default
cargo test --workspacegreen; vitest 202; typecheck/fmt/clippy clean. No in-repo test needed updating (no test hard-coded the old controller layouts). - Capture status: 17/17 real — the registry is DONE. (The
DetectorKindenum also has ZK / KRC721 / KasBonds variants, but those were never registry fingerprint entries — ZK is a future family, KRC721/KasBonds use inscription-envelope parsing, not script fingerprints.) Next strategic tracks (no fingerprint work left): (1)persist_lineagefork model — the single-head lineage double-advances on KCC20 transfer forks; decide whatcovenant_ididentifies and fix population/reorg; (2) typed-schema deploy-time SDL storage + routingexecute_query/get_schemathroughexecuteSubgraphQuery(couples to deploy pipeline); (3)deploy/status/logs/removeCLI + Phase 5 hosted infra.
The mechanical, self-checking recipe paid off: captured every remaining fixed-size OpenSilver core pattern in one batch. All 12 core patterns are now real exact fingerprints — the placeholder era is over for the core set; only the 4 KCC20 controller entries remain synthetic.
- 8 new exact
Fingerprints (one module each):opensilver_escrow_bilateral(buyer/seller/arbiter_hash/timeout),opensilver_escrow_milestone(+total/completed_milestones),opensilver_streaming_payment(sender/recipient/rate/total/remaining_allowance/period/next_release_time),opensilver_vesting(beneficiary/admin/total_allocation/claimed/cliff/period/release_per_period/revocable),opensilver_dead_mans_switch(owner/fallback/timeout_age/last_ping_age),opensilver_social_recovery(owner/has_pending_owner/pending_owner/guardian_threshold/guardian1-3/activation_time/recovery_delay),opensilver_atomic_swap_htlc(recipient/refunder/secret_hash/timeout),opensilver_freelance_payroll(client/worker/arbiter_hash/timeout). All real contract field names + widths, replacing the placeholders' guessed layouts. - Captured via a single generator pass that, per pattern: compiles
.siltwice (canonical + distinct-state instance) viasilverc, computes windows fromstate_layoutby the push-prefix rule, self-checks (every byte-diff inside a window AND last window ends exactly atstart+len) and verifies extraction, writes both hexes totestdata/, and emits the full.rsmodule (fingerprint fn + 3 tests asserting validate / match-instance-and-extract-all-fields / reject). All 8 passed the self-check on the first compile. - Registry: 8 placeholder
opensilver(...)entries →PatternMatcher::Exact(...).detector-schema.tsregenerated. Registry module/header doc comments updated (no longer "placeholders pending capture" — most entries are real now). Cross-collision test passes with all 12 exact + 1 anchored real fingerprints mixed among the 4 remaining KCC20-controller placeholders. - detectors lib 128 (+24 = 8×3); default
cargo test --workspacegreen; vitest 202; typecheck/fmt/clippy clean. No in-repo test needed updating this time (the only hard-coded layout assertions — Vault/MultiSig — were already real). - Capture status: 13 real / 4 placeholders. Remaining: KCC20OwnableController / PausableController / CappedController / VestingController. Next: capture these 4 — but first check
contracts/tokens/kcc20-*.silformaxCovIns/maxCovOutsloop params: if present they need anchored fingerprints viaderive_anchored_fingerprint(compile at ≥2 bound settings, like KCC20Asset); if fixed-size, the same exact recipe applies. That closes the entire registry. Other open tracks:persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5, typed-schema deploy-time SDL storage.
Second registry capture this session, using the now-mechanical self-checking recipe end-to-end with no surprises.
opensilver_multisig_fingerprint()— a real exactFingerprintformultisig.sil(fixed 3-signer N-of-3 quorum, no loop params). State(threshold, pk1, pk2, pk3). Windows (computed fromstate_layout {start:1, len:108}via the push-prefix rule, then confirmed by diffing two distinct-state compiles — every differing byte inside a window, state region ends exactly at offset 109 = start+len): threshold[2..10) (8-byte LE int), signer_pubkey_1[11..43), signer_pubkey_2[44..76), signer_pubkey_3[77..109). 876-byte script. Canonical + distinct instance committed astestdata/opensilver_multisig_{script,instance}.hex+include_str!'d. 3 tests.- Registry:
OpenSilverMultisigswitched from the placeholder (which wrongly bundled the three keys into one 96-bytesigner_pubkeyswindow + a 1-bytethreshold) toPatternMatcher::Exact(...).detector-schema.tsregenerated. Two in-repo tests that hard-coded the old placeholder layout updated to the real fields: the Rustregistry_schema_fields_match_masked_windows(its window→schema-mapping purpose preserved) and the TScli-codegendiscriminated-union assertion. - detectors lib 104 (+3); default
cargo test --workspacegreen; vitest 202; typecheck/fmt/clippy clean. - Registry capture status: 5 real / remaining placeholders. Real now: Ownable, TimeLock, Vault, MultiSig (all exact, fixed-size core patterns) + KCC20Asset (anchored). All four remaining OpenSilver core patterns I've checked are fixed-size (no
maxCovIns/maxCovOuts) — so Escrow (Bilateral/Milestone), Vault-adjacent, Streaming, Vesting, DeadMansSwitch, SocialRecovery, AtomicSwapHTLC, FreelancePayroll are likely all exact captures via this same recipe; the KCC20 controller family (Ownable/Pausable/Capped/Vesting controllers) may loop. Next: keep capturing the fixed-size core patterns (fast, self-checking) — or settle whether any remaining.silcarries loop params (→ anchored viaderive_anchored_fingerprint). Other open tracks:persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5, typed-schema deploy-time SDL storage.
The first registry capture this session, and the most state-rich exact pattern so far — proving the push-prefix offset rule scales to a 9-field covenant.
opensilver_vault_fingerprint()— a real exactFingerprintforvault.sil, captured from asilverccompile. Vault is fixed-size (nomaxCovIns/maxCovOutsloop params), so exact match. Its real state is the nine-field tuple(owner, has_pending_owner, pending_owner, threshold, pk1, pk2, pk3, unlock_time, beneficiary)— owner slot w/ two-step handoff + N-of-3 signer quorum + timelocked beneficiary release. Windows (computed via the push-prefix rule fromstate_layout {start:1, len:218}, then confirmed by diffing two distinct-state compiles — every differing byte falls inside one of the nine windows and nowhere else): owner[2..34), has_pending[35], pending_owner[37..69), threshold[70..78) (8-byte LE int), pk1[79..111), pk2[112..144), pk3[145..177), unlock_time[178..186) (8-byte LE int), beneficiary[187..219). Canonical script + a distinct instance committed astestdata/opensilver_vault_{script,instance}.hexandinclude_str!'d. 3 tests (validate, match real instance + extract all 9 fields incl. LE int decode, reject unrelated).- Registry:
OpenSilverVaultswitched from the placeholderopensilver(...)(which declared a wrong 3-field guess: owner/recovery_pubkey/recovery_delay_blocks) toPatternMatcher::Exact(opensilver_vault_fingerprint()).cli/src/detector-schema.tsregenerated fromdump-registry— the Vault payload type now carries the real 9 fields. Cross-collision test passes with the new 2190-byte exact fingerprint mixed among the placeholders + the other 3 real captures. - detectors lib 101 (+3); default
cargo test --workspacegreen; vitest 202; typecheck/fmt/clippy clean. (Also fixed a pre-existing clippymanual_repeat_nlint in the placeholder builder — same file, trivial.) - Capture recipe (now mechanical, reusable for the remaining ~13): compile the
.siltwice viaOpenSilver/upstream/silverscript/target/debug/silverc --ctor <args.json>with two distinct state-value sets (pubkey ctor arg ={kind:array,data:[{kind:byte,data:N}×32]}, int ={kind:int}, bool ={kind:bool}); readstate_layout, compute field offsets viastart + 1 + Σ_{j<i}(width_j+1), assert the byte-diff between the two compiles lands only inside those windows (catches any offset error), write both hexes to testdata, emit theFingerprint/AnchoredFingerprintmodule, register it, regendetector-schema.ts. Best automated as thextask— but the manual recipe is now fast and self-checking. - Next: the next placeholder capture (Multisig is the natural follow — but it has
maxCovIns/maxCovOutsloop params, so it needs an anchored fingerprint viaderive_anchored_fingerprintover ≥2 bound settings, like KCC20; the remaining core patterns Escrow/Streaming/Vesting/etc. are also loop-parameterized). Other open tracks:persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5, typed-schema deploy-time SDL storage.
Closes the last logic gap the typed-schema feature carried: cross-entity relation traversal. Queries can now walk both direct references (bond: Bond!) and reverse @derivedFrom relations (holdings: [Holding!] @derivedFrom(field: "bond")), each resolving from entity_versions rather than only the inline payload.
attachRelationResolvers(schema, entityNames, pool, subgraphId)(@kasgraph/api) — after schema build + scalar patching, walks every@entityobject type's fields; for each field whose named type is itself an entity it installs afield.resolve: a@derivedFrom(field: F)field resolves viasubgraphEntitiesWhere(pool, subgraphId, targetType, F, source.id)(the reverse-relation filter); any other entity-typed field resolves the stored foreign-key id viasubgraphEntityById(passing through an already-materialized object if the payload carries one). Wired intoexecuteSubgraphQuery(computesentitiesonce, shares it with the rootValue loop).subgraphEntitiesWhere(pool, subgraph, entityType, field, value)(pg-resolvers) —SELECT DISTINCT ON (entity_id) … WHERE entity_type = $1 AND payload->>$2 = $3 ORDER BY entity_id, block_daa_score DESC, latest-version-per-id, returning merged payloads. Schema name still validated viavalidatedSchema(injection-safe).- 2 new tests (a
@derivedFromreverse relation returns the filtered children + binds['Holding','bond','b1']; a directHolding.bondreference loads the referenced Bond by id, binding['Bond','b1']). vitest 202; typecheck clean. No Rust change. - The typed-schema feature is now logically complete end-to-end (scalars + lists + by-id + relations). What remains is purely runtime plumbing, not logic: store each subgraph's
schema.graphqlat deploy time + cache the built schema, and routeexecute_query(subgraph_id, …)/get_schemathroughexecuteSubgraphQuery/buildSubgraphSchemaSdl(couples to the deploy pipeline / Phase 5). - Other open tracks unchanged: capture xtask for remaining fingerprints,
persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5.
Builds on the schema-generation core: typed subgraph queries now actually run against entity_versions.
executeSubgraphQuery({subgraphId, sdl, query, variables, pool})(@kasgraph/api) — builds + scalar-patches the subgraph schema, wires per-entity resolvers (<entity>(id)→subgraphEntityById,<entity>s(first)→subgraphEntitiesOfType, both reading"<schema>".entity_versionsand returning the payload object so GraphQL's default field resolution serves the typed scalar fields), and parse/validate/executes. Build/parse/validation errors come back inerrors.- Supporting: factored
patchKasGraphScalars(shared by base + subgraph schemas); addedsubgraphEntityById/subgraphEntitiesOfTypetopg-resolvers(return{id: entity_id, ...payload}soid: ID!always resolves, payloadidwins); exportedvalidatedSchema. - 3 new tests (typed list serves payloads incl. BigInt/Boolean + id fallback to
entity_id; by-id binds(entityType, id); unknown-field validation error issues no query). vitest 200; typecheck clean. No Rust change. - The typed-schema feature is now functionally complete — what remains is runtime plumbing, not logic: (1) store each subgraph's
schema.graphqlat deploy time + cache the built schema, and routeexecute_query(subgraph_id, …)/get_schemathroughexecuteSubgraphQuery/buildSubgraphSchemaSdl(couples to the deploy pipeline / Phase 5); (2) relation /@derivedFromcross-entity resolution (fields are in the schema but resolve only from payload today). - Other open tracks unchanged: capture xtask,
persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5.
The pure core of the headline "each subgraph gets its own typed GraphQL API" feature.
@kasgraph/api/subgraph-schema.ts:subgraphEntities(sdl)(finds the@entityobject types) +buildSubgraphSchemaSdl(sdl)(declares the@entity/@derivedFromdirectives +BigInt/JSONscalars, keeps the entity types verbatim, and auto-generates aQuerywith<entity>(id: ID!): Tand<entity>s(first: Int = 100): [T!]!per entity) +buildSubgraphSchema(sdl)(→ a validatedGraphQLSchema). 5 tests (entity discovery skips non-@entitytypes; generated SDL has the right query fields + scalars; no-entity SDL throws; the built schema's Query exposes exactly the entity fields; a scalar query validates). vitest 197.- Why it's the core, not the whole feature: entity payloads are the field map, so default field resolution serves scalar fields directly once the query resolver returns
entity_versionspayloads — but that resolver wiring, per-subgraph schema caching inexecute_query(currently single base schema), and storing each subgraph'sschema.graphqlat deploy time (so the gateway knows a subgraph's types) are the remaining steps. Relation/@derivedFromcross-entity resolution is also deferred (relation fields are kept in the schema but only resolve if the payload carries them). - Next: wire
buildSubgraphSchema+ entity resolvers into a per-subgraphexecute_querypath (needs SDL storage — couples to the deploy pipeline / Phase 5). Other open tracks: capture xtask,persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5.
Exposes the spend records the now-live CovenantSpent dispatch persists, mirroring the entity resolver.
@kasgraph/api:CovenantSpend { spendingTxHash, previousTxHash, previousOutputIndex, blockDaaScore, detectorKind, covenantId, spentValueSompi, successorCovenantId }type +covenantSpends(subgraph, covenantId, first)Query.PgGatewayResolvers.covenantSpendsreads the per-subgraph"<schema>".covenant_spends(same validated schema interpolation asentity), optionalcovenant_idfilter, ordered by DAA desc. (operationisn't on the record — its effect lands in entity state, queryable viaentity.) Wired intoserver.ts+InMemoryResolvers+GatewayResolvers.- Verified: typecheck clean; vitest 192 (+2 resolver tests: BIGINT/nullable mapping + covenant-id filter). No Rust change.
- Next query-layer: typed per-subgraph GraphQL schema (entities/data are generic
JSONtoday). Other open tracks: capture xtask for remaining fingerprints;persist_lineagefork model;deploy/status/logs/removeCLI + Phase 5.
The headline product gap: the indexer persisted mapping-emitted entities (entity_versions) but the GraphQL gateway had no way to read them. Now it does — closing the loop from "indexed" to "queryable."
@kasgraph/api: newEntity { entityType, entityId, data: JSON, blockDaaScore }type +entity(subgraph, entityType, id)andentities(subgraph, entityType, first)Query fields.PgGatewayResolvers.entity/entitiesread the per-subgraph"<schema>".entity_versionstable —entityreturns the highest-block_daa_scorerow;entitiesusesDISTINCT ON (entity_id) … ORDER BY entity_id, block_daa_score DESCfor latest-per-id. The subgraph id is interpolated into the schema-qualified name, so it's validated to^[a-z0-9_]+$(matching the store'sSubgraphId) and rejected otherwise — injection-safe (tested: an injection-y id throws before any query runs). Wired intoserver.ts;InMemoryResolvers(server test) + theGatewayResolversinterface updated.- MCP gets it for free:
execute_querydelegates to the same gateway, so MCP clients can query entities via the new fields with no MCP change. - Verified: typecheck clean; vitest 190 (+4 resolver tests: single-entity bind/map, missing→null,
entitiesDISTINCT-ON + bounded LIMIT, injection rejection). No Rust change. - Next / future enhancements: (1) typed per-subgraph GraphQL schema — currently entities are generic (
data: JSON); a subgraph author would want theirschema.graphqltypes (bonds { id owner }) — needs dynamic schema generation per deployed subgraph. (2) exposecovenant_spendsas a query too (parallelsdetectedPatterns). (3) the still-open tracks: capture xtask for remaining fingerprints,persist_lineagefork model,deploy/status/logs/removeCLI + Phase 5.
Previous commit arc (2026-05-29 — third real fingerprint (TimeLock) + the push-prefix offset rule is proven)
opensilver_timelock_fingerprint()— a real exactFingerprintfortimelock.sil(fixed-size; stateowner/beneficiary/unlock_time/soft_cancel). Window offsetsowner_pubkey[2..34)/beneficiary_pubkey[35..67)/unlock_time[68..76)(8-byte LE i64) /soft_cancel[77]. The placeholder declared only 2 (wrong) fields; the real 4-field layout is now in the schema. 3 tests; testdata hexinclude_str!d.- Key result for automation — the push-prefix offset rule is now validated on 3 patterns (KCC20 anchored, Ownable + TimeLock exact, across
byte[32]/byte/int/boolfields): each state value is preceded by exactly one push-opcode byte, so a field's offset is computable asstate_layout.start + 1 + Σ_{j<i}(width_j + 1)— no per-field diffing needed, andstart+lenaccounts for the whole slot exactly. So the capture xtask is now straightforward: compile once (or at 2 bounds for loop-parameterized), readstate_layout, take the ordered (field, width) list, compute windows, emit the fingerprint (Exact if fixed-size, Anchored otherwise). - Registry:
OpenSilverTimeLockswitched toPatternMatcher::Exact(...). Three real fingerprints now coexist (Ownable + TimeLock exact, KCC20 anchored); cross-collision clean. Theevery_*_discriminatortest was scoped to0xFE-prefixed placeholders (real captures share a SilverScript prologue, so the discriminator scheme doesn't apply — content-distinctness is the cross-match test's job). - detectors lib 98; default workspace 257; vitest 186; typecheck/fmt/clippy clean.
- Next: the capture xtask (now low-risk given the proven offset rule — needs only a per-pattern ctor recipe + the registry's field list; emit consts programmatically) to finish the remaining ~13 placeholders. Plus the open strategic tracks:
persist_lineagefork-handling,deploy/status/logs/removeCLI + Phase 5, Phase 3 query-surface expansion.
Generalizes the real-fingerprint capture beyond KCC20/anchored to a fixed-size (non-looping) pattern.
opensilver_ownable_fingerprint()— a real exactFingerprintforownable.sil, captured from asilverccompile. Ownable takes no loop-bound ctor params (state is justowner/has_pending/pending_owner), so it's fixed-size → exact match (no anchoring). State windows verified:owner_pubkey[2..34),has_pending[35],pending_owner_pubkey[37..69). Canonical script + a distinct-state instance committed astestdata/*.hexandinclude_str!d (not hand-inlined — the KCC20-tail-truncation lesson). 3 tests (validate, match real instance + extract, reject unrelated).- Registry:
OpenSilverOwnableswitched from the placeholderopensilver(...)toPatternMatcher::Exact(opensilver_ownable_fingerprint()). The capture revealed the placeholder omitted thehas_pendingflag — the real 3-field layout is now in the schema (detector-schema.tsregenerated; codegen + 186 TS tests green; theOpenSilverOwnableStateinterface gainshas_pending). Cross-collision test passes with two real fingerprints (Ownable exact 420B + KCC20 anchored) mixed among placeholders. - detectors lib 95; default workspace 254; node 64 (its tests build scripts from the entry's own bytes, so they adapted); fmt/clippy clean.
- Next: the remaining placeholders are the per-pattern capture toil this proves out — best automated by the capture xtask (compile each
.silat its ctor recipe, diff for state windows, emit Exact or Anchored consts programmatically). Plus the still-open tracks:persist_lineagefork-handling (design decision),deploy/status/logs/removeCLI + Phase 5, and Phase 3 query-surface expansion.
The culmination of the detection/operation thread: the node now dispatches CovenantSpent for real KCC20 covenant spends, classifying the operation from on-chain receipt state.
- Node spend loop wired (
apply_and_persist_notification): groups the block's covenant-spending inputs byspending_tx_hash; for each spending tx, gathers the consumed receipts (lookup_covenant_utxoper input) + created receipts (covenant_utxos_created_by_tx); for a homogeneous KCC20 tx,kcc20_spend_operation(consumed, created)resolves the operation; per consumed covenant it records the spend, buildsCovenantSpend { operation, spent_value_sompi, successor_covenant_id }, resolves theCovenantSpenthandler via the descriptor, callsmapping_host::dispatch_spend_hit(seeded from the committed snapshot), and persists the emitted entity versions. Operation stays undetermined (no dispatch) for non-KCC20 or mixed txs — never a guess. The#[allow(dead_code)]came offdispatch_spend_hit/spend_mapping_event. - End-to-end integration test (
committed_spend_dispatches_covenant_spent, against live Postgres): block 1 locks a real KCC20 covenant (the committedsilvercinstance fixture, detected via the real anchored fingerprint); block 2 consumes its outpoint + produces a successor KCC20 output; the test asserts theCovenantSpenthandler fired and persisted its entity — exercising detection → receipt extraction → operation classification → handler resolution → dispatch → persist. node integration 4/4; store integration 9/9; default workspace 251; fmt clean. - Remaining (no longer blocking the core loop): (1)
persist_lineagefork-handling — KCC20 transfers create ≥2 same-id outputs; the single-head lineage model (covenant_lineage_head/_row) double-advances on a fork. Decide whatcovenant_ididentifies (whole token w/ forks, vs minter-branch-only) and fix population/reorg accordingly; this affects the GraphQL lineage view, not spend dispatch. (2) The anchored-fingerprint capture xtask for the other loop-parameterized patterns (per-contract ctor args; emit head/tail consts programmatically). (3)deploy/status/logs/removeCLI + Phase 5 hosted infra. (4) Phase 3 GraphQL/MCP/streaming query surface (NOT_STARTED — large clear product surface).
kasgraph_detectors::kcc20_spend_operation(consumed: &[Value], created: &[Value])— the bridge the node spend path calls: parses each consumed/created receipt'slocked_statepayload intoKcc20ReceiptStateand runsclassify_kcc20_operationover the sets, returning the operation (or a decode error so the caller leaves it undetermined rather than guessing). 2 tests (1→2 transfer reads as transfer; minter+new-holder reads as mint; malformed payload propagates). detectors lib 92; default workspace 251; fmt/clippy clean.- Correction to the prior arc's framing: the KCC20 lineage-fork issue is orthogonal to spend dispatch, narrower than first flagged.
successorCovenantIdalready usescovenant_lineage_continues(a fork-agnosticEXISTS), andkcc20_spend_operation/classify_kcc20_operationare set-based (fork-safe). The only fork-sensitive piece ispersist_lineage's single-head advancement on the lock path (it would double-advance when a tx creates ≥2 same-id outputs) — a separate query-layer concern that does not block operation/successor/dispatch and can be fixed independently. So the spend dispatch can be wired correctly now. - Next — the node spend-loop wiring (now mechanical, all pieces exist): in the committed-block spend loop, group inputs by
spending_tx_hash; for each spending tx, collect the consumed receipts (the inputs'lookup_covenant_utxomatches) and the created ones (covenant_utxos_created_by_tx); forKCC20Asset,kcc20_spend_operation(consumed_states, created_states)→ operation; buildCovenantSpend { operation: op.as_str(), spent_value_sompi: value.to_string(), successor_covenant_id };descriptor.resolve_handler(detector_kind, EVENT_COVENANT_SPENT)→ handler;mapping_host::dispatch_spend_hit(runtime, &spend, prior_state=locked_state, daa, hash, handler, subgraph, snapshot); persist the emitted entity versions; replace the spendinfo!log. Verify with a node integration test (KCC20 lock block via the real fingerprint, then a spend tx with covenant in+out, asserting the spend handler dispatched + entity persisted with the right operation). Separately, fixpersist_lineagefork-handling. Thendeploy/status/logs/removeCLI + Phase 5.
Toward wiring classify_kcc20_operation into the spend path. Reading the spend loop surfaced that correct classification needs data the store didn't expose, plus a real design question I'm flagging rather than papering over.
Store::covenant_utxos_created_by_tx(subgraph, tx_hash) -> Vec<CovenantUtxoMatch>— the covenant outputs a transaction created (the successor receipt set), ordered by output index.classify_kcc20_operationis a pure function of the consumed-vs-created receipt sets, and only the consumed half (lookup_covenant_utxoper input) existed; this is the created half. SQL-builder unit test + a real-Postgres integration test (two outputs of one tx returned in order, other txs excluded, empty for unknown tx). store unit 20 / store integration 9 / default workspace 249; fmt/clippy clean.- Design questions to settle before the spend-dispatch wiring (do NOT shortcut — a wrong operation misleads the spend mappings, which is why dispatch was deferred):
- Set-based, per-tx classification. The spend loop currently iterates inputs one at a time. To classify correctly it must group by
spending_tx_hash, gather the consumed receipts (the inputs'lookup_covenant_utxo) AND the created receipts (covenant_utxos_created_by_tx), thenclassify_kcc20_operation(consumed, created)(KCC20Asset only; other kinds stay undetermined). A single input-vs-single-output shortcut is wrong for a 1→2 transfer (it misses the sum). - KCC20 per-UTXO transfers FORK the lineage. A transfer spends 1 receipt and creates ≥2 (sender change + recipient), and
classify_lineagemakes every output whose tx consumes a tracked covenant inherit the predecessor'scovenant_id— so multiple same-id outputs. The current single-head lineage model (upsert_covenant_lineage_head+covenant_lineage_continues+ the reorg head re-point) assumes a linear chain and will double-advance / mis-resolvesuccessorCovenantIdon a fork. This needs a decision: either covenant_id identifies the token (forks are expected; replace the single-head model with a per-receipt/UTXO-set model) or only the minter/controller branch carries the lineage id (holder receipts get their own ids). This affects lineage population,successorCovenantId, and reorg unwind — resolve it before dispatch.
- Set-based, per-tx classification. The spend loop currently iterates inputs one at a time. To classify correctly it must group by
- Then: with sets gathered + lineage model settled, build
CovenantSpend { operation, spent_value_sompi, successor_covenant_id }, resolve theCovenantSpenthandler via the descriptor (keyed on the locked detector kind), seed prior state fromlocked_state, callmapping_host::dispatch_spend_hit, persist the emitted entity versions, and replace the spendinfo!log. Verify with a node integration test (a KCC20 lock then a spend tx with covenant in+out). Thendeploy/status/logs/removeCLI + Phase 5.
The real KCC20 fingerprint is now actually used by detect_in_output — the registry recognizes a KCC20 asset covenant on chain, not a placeholder.
kasgraph_detectors::PatternMatcherenum —Exact(Fingerprint)for fixed-size patterns,Anchored(AnchoredFingerprint)for loop-parameterized ones. Dispatchesmatch_and_extract/matches/windows/validate, plussample_script(canonical bytes for exact;head++tailfor anchored) for the registry self-consistency tests.DetectorEntrynow holds amatcher: PatternMatcher(wasfingerprint: Fingerprint). Theopensilver()placeholder helper buildsExactentries;KCC20Assetis nowAnchored(kcc20_asset_fingerprint())— the first registry entry backed by a real compiled-script signature.detect_in_output+registry_schemadispatch throughmatcher;dump-registry's KCC20Asset schema is byte-for-byte unchanged (owner_identifier 32 / identifier_type 1 / amount 8 / is_minter 1), socli/src/detector-schema.tsneeds no regen.- Registry tests adapted: discriminator-uniqueness now scoped to
Exactentries (anchored ones are distinguished by head/tail content); self-match + cross-collision usematcher.matches(matcher.sample_script())— and the cross-collision test passes with the real 218-byte anchored KCC20 signature mixed among the placeholders (length + content keep them disjoint). Node test fixtures that build a matching OpenSilverOwnable script updated to pattern-matchPatternMatcher::Exact. - Verified: detectors lib 90; default
cargo test --workspace248; node integration 3/3 (incl. the persist-loop POI test) against live Postgres; fmt/clippy clean. - Next: (1) generalize the capture to the other loop-parameterized patterns — an
xtaskthat compiles each.silat ≥2 bounds (per-contract ctor args), maps state offsets, runsderive_anchored_fingerprint, and emits the head/tail consts programmatically (hand-copying hex is error-prone — see the prior arc's truncation), plus the 17-kind cross-collision check. (2) Wireclassify_kcc20_operationinto the node spend path: when a tracked covenant UTXO is spent, gather the spent + successorKCC20Assetreceipt states (now honestly extractable via the real fingerprint), classify → fillCovenantSpend.operation→ swap the spendinfo!log fordispatch_spend_hit. Thendeploy/status/logs/removeCLI + Phase 5.
Previous commit arc (2026-05-29 — FIRST REAL FINGERPRINT: KCC20 asset, captured + verified, LE bug fixed)
The milestone: the first covenant fingerprint built from real silverc output, verified end-to-end against a real on-chain instance — and the verification caught a real decoder bug.
kasgraph_detectors::kcc20_asset_fingerprint()— a realAnchoredFingerprintfor the native KCC20 asset covenant, captured by compilingkcc20.silat bounds (4,4) and (8,8) and runningderive_anchored_fingerprint: stable 64-byte head (state masked) + 154-byte tail, with windowsowner_identifier[2..34)/identifier_type[35]/amount[37..45)/is_minter[46]. The real head/tail bytes are embedded as hex consts; a real compiled instance at an unseen bound (5,5) with entirely different state is a committed test fixture (src/testdata/kcc20_asset_instance.hex). The test asserts the fingerprint matches it and extracts the right field bytes.- Bug caught + fixed by the real-data verification: SilverScript stores
intlittle-endian on chain —amount999 extracts ase703000000000000, not big-endian.Kcc20ReceiptState::from_payloadwas parsing big-endian (would mis-decode every real amount); now uses a little-endian u64 decode (identifier_type/is_minterare 1-byte, unaffected). The end-to-end test ties it together: real instance → fingerprint extract →from_payload→amount == 999. - detectors lib 90; default
cargo test --workspace248; fmt/clippy clean. NB: hand-copying 154 bytes of hex is error-prone — I truncated the tail const by 12 bytes on the first try and the match test caught it; the eventual capture xtask should write these consts programmatically. - Next — wire it into detection + the spend path: (1) make
DetectorEntryhold either an exactFingerprintor anAnchoredFingerprint(enum), updatedetect_in_outputto dispatch andregistry_schema/dump-registryto readhead_masked_windowsfor anchored entries; swapKCC20Asset's placeholder forkcc20_asset_fingerprint(). (2) Generalize the capture to the other loop-parameterized patterns via an xtask (per-contract ctor args; programmatic const emission) + the 17-kind cross-collision check. (3) Wireclassify_kcc20_operationinto the node spend path: gather spent + successor receipt states (now honestly extractable) → classify → fillCovenantSpend.operation→dispatch_spend_hit. Thendeploy/status/logs/removeCLI + Phase 5.
The algorithmic core of the per-pattern signature capture, pure + tested, so the remaining capture work is just "run silverc + feed this".
kasgraph_detectors::fingerprint::derive_anchored_fingerprint(scripts, state_windows)— takes the compiled scripts of one pattern at ≥2 loop-bound settings (fixed state, varying bounds) and returns anAnchoredFingerprint:head= their longest common prefix,tail= longest common suffix, the loop-unrolled middle dropped;state_windowsmark the (fixed-across-samples, varies-per-instance) state region masked inside the head. Errors on <2 samples, head/tail overlap in the shortest script (ambiguous signal), or a state window outside the derived head. Pure — the caller owns invokingsilverc.- 3 tests over structurally-faithful inputs (shared head with masked state + variable middle + shared tail): derives the right head/tail, matches all samples + an unseen bound + extracts the masked state, rejects a different pattern; plus the error paths. detectors lib 87; default workspace 245; fmt/clippy clean.
- Next narrows to the capture harness + wiring: (1) a script/
xtaskthat, per pattern, compiles its.silat ≥2 bounds viaOpenSilver/upstream/silverscript/target/debug/silverc(raw ctor-args JSON — each contract's constructor differs), maps its state-window offsets by single-field diffs (as done for KCC20Asset: owner[2..33]/type[35]/amount[37..44]/minter[46]), and callsderive_anchored_fingerprint→ emit the registry entry. (2) Cross-pattern non-collision check across the 17 kinds. (3) MakeDetectorEntryhold either an exactFingerprintor anAnchoredFingerprintand updatedetect_in_outputto dispatch. (4) Wireclassify_kcc20_operationinto the node spend path. Thendeploy/status/logs/removeCLI + Phase 5.
Resolved the open question from the prior arc and landed the foundational engine piece for the viable detection path.
- Open question resolved — head/tail DO distinguish patterns. Compiled Ownable (420B), TimeLock (368B), and KCC20 (2728B) and compared: across different patterns the common prefix is only ~34 bytes (generic SilverScript scaffold + the masked state region at offset 1) and the common suffix is tiny (2 bytes KCC20-vs-core, 14 between the two core patterns). So a pattern's tail — and its head beyond the ~34B shared scaffold — is strongly pattern-specific. Anchored head+tail matching is therefore sound for discrimination (choose head/tail long enough to clear the ~34B shared prologue).
kasgraph_detectors::fingerprint::AnchoredFingerprint(new) —{ head, head_masked_windows, tail }.matchesrequires the script to start withhead(masked bytes wildcarded) and end withtail, withlen >= head.len()+tail.len()so they can't overlap; the loop-unrolled middle is unconstrained, so one signature matches a pattern at anymaxCovIns/maxCovOuts.match_and_extractpulls the head-relative state windows (bound-independent offsets).validatebounds/overlap-checks the windows. 5 unit tests (middle-length independence for match + extract, wrong head/tail byte rejection, too-short rejection, validate). The exact-matchFingerprintstays for fixed-size patterns. detectors lib 84; default workspace 242; fmt/clippy clean.- Next (the remaining real subsystem), in order: (1) an
xtask/script that, per pattern, compiles its.silat ≥2 loop-bound settings via the builtsilverc, intersects the scripts to extract the stable head (with state masked perstate_layout+ the diff-mapped field offsets) and stable tail, and emits anAnchoredFingerprint— needs valid ctor args per contract (each.sil's constructor differs; see their signatures). (2) Verify each captured signature matches a real instance at an unseen bound + extracts the right fields, and assert no two patterns' head/tail collide (the 17-kind cross-collision check). (3) Switch the registry +detect_in_outputto anchored fingerprints for the loop-parameterized kinds (KCC20 family + the OpenSilver core patterns, which also loop). (4) Then wireclassify_kcc20_operationinto the node spend path: gather the spent + successor receipt states (now honestly extractable) → classify → fillCovenantSpend.operation→ swap the spendinfo!log fordispatch_spend_hit. Thendeploy/status/logs/removeCLI + Phase 5.
Previous commit arc (2026-05-29 — real-fingerprint reconnaissance: byte-exact is out, anchored matching is in)
Drilled into the actual silverc compiler to drive the fingerprint sync, and the reconnaissance changed the detection strategy. Landed one verified correctness fix; the rest is a precise, evidence-backed design hand-off (no speculative code — a wrong fingerprint silently breaks detection).
Verified fix shipped: the KCC20Asset amount field is an 8-byte i64, not the placeholder's 16 — confirmed by compiling kcc20.sil and isolating the field. Registry width + kcc20_operation::from_payload (now parses ≤8B) + regenerated cli/src/detector-schema.ts updated. detectors 79 / default workspace / TS 186 / typecheck / fmt / clippy all green.
How to compile a pattern (reproducible): silverc is built at OpenSilver/upstream/silverscript/target/debug/silverc. Invoke directly with a raw constructor-args JSON file (the CLI's scalar --ctor can't express byte params): silverc --constructor-args <ctor.json> contracts/tokens/kcc20.sil --output <art.json>, where ctor.json is an array of {kind:'int'|'bool'|'byte'|'array', data} (a byte[32] is {kind:'array', data:[{kind:'byte',data:N}×32]}). The artifact JSON has script (number[], the redeem bytes) and state_layout {start, len} (debug_info is null; no per-field offsets are exported — derive them by diffing).
Exact KCC20Asset state layout (empirically mapped, bound-INDEPENDENT): state_layout = {start:1, len:46} holds across all loop bounds. Within the script: owner_identifier bytes [2..33] (32), identifier_type [35] (1), amount [37..44] (8), is_minter [46] (1); the gaps (1, 34, 36, 45) are fixed structural/push-prefix bytes. Isolated single-field diffs give these cleanly.
The blocker that killed the original plan: SilverScript unrolls loops into the script, so the non-state ctor params maxCovIns/maxCovOuts change the fixed bytes drastically — kcc20.sil compiles to 2728 bytes at 4/4, 5104 at 8/8, 3134 at 4/6. A single byte-exact whole-script fingerprint therefore only matches one (maxCovIns,maxCovOuts) combo. Byte-exact fingerprinting (the registry's current model + the old sync-opensilver-fingerprints plan) is not viable for these parameterized covenants.
The viable approach (next real subsystem): an anchored prefix+suffix matcher. Across bound variants there's a stable 64-byte head (which contains the masked 46-byte state region at offset 1) and a stable 154-byte tail; only the unrolled middle varies. So: extend the fingerprint engine with an anchored mode — match a fixed head + fixed tail, skip the variable middle, with the state windows masked inside the head; extraction uses the bound-independent head offsets above. Steps: (1) engine: add anchored head/tail matching to kasgraph_detectors::fingerprint (keep the exact mode for fixed-size patterns); (2) capture the stable head+tail signatures per pattern from real compiles (an xtask that compiles at ≥2 bound settings and intersects); (3) verify a real instance at an unseen bound matches + extracts; (4) check head/tail don't collide across the 17 patterns; (5) only then wire classify_kcc20_operation into the node spend path (gather spent+successor receipts → classify → fill CovenantSpend.operation → dispatch_spend_hit). Open question to settle first: are the 64-byte head / 154-byte tail pattern-distinguishing, or do sibling patterns share them (then anchor on a larger or content-specific region)?
Investigating the real kcc20.sil (to drive the fingerprint sync) surfaced a contradiction: KasGraph's registry + reference doc + the just-committed operation decoder assumed an aggregate asset model (total_supply/mint_nonce), but the actual reference contract is per-UTXO receipts (ownerIdentifier/identifierType/amount/isMinter) — no aggregate supply field exists. Per user decision (adopt the real model), reworked everything to match kcc20.sil.
kcc20_operationrewritten to the receipt-set model.Kcc20ReceiptState { owner_identifier, identifier_type, amount, is_minter };classify_kcc20_operation(prev: &[…], next: &[…])over input/output receipt sets: summedamount↑ →Mint, ↓ →Burn(only a minter branch may change the sum, percheckAmounts); supply conserved + minter-branch controller binding (is_minter+COVENANT_IDowner_identifier) changed →RotateController; elseTransfer.as_str()still emitstransfer/mint/burn/rotate_controller.Kcc20ReceiptState::from_payloadparses the per-UTXO hex field map. 10 unit tests (incl. "a non-minter owner change alone is a transfer, not a rotation"). Re-exports renamed (Kcc20ReceiptState,classify_kcc20_operation).- Registry
KCC20Assetfields updated toowner_identifier(32)/identifier_type(1)/amount(16)/is_minter(1);field_labelarms swapped;cli/src/detector-schema.tsregenerated fromdump-registry(old aggregate fields gone). TS suite still 186, typecheck clean (codegen consumed the new schema cleanly). KRC20_KRC721_REFERENCE.mdcorrected — the "KCC20 architecture" + "Per-holder balances" sections now describe the per-UTXO receipt model and operation inference, instead of the aggregate asset covenant thatkcc20.silnever implemented; thekasgraph_kcc20_balancetable is reframed as a current-owner projection over the receipts.- Verified: detectors lib 79; default
cargo test --workspace237; fmt/clippy/typecheck clean. - Still not wired; remaining blocker unchanged in shape: real state extraction — the
cargo xtask sync-opensilver-fingerprintstask. Good news: it's now demonstrably feasible —upstream/silverscript/target/debug/silvercis built andopensilver compile contracts/tokens/kcc20.sil --ctor '[...]'emits a real compiled redeem script ({contract_name, script:[bytes]}); state lives spliced into the script at fixed offsets, so the masked state-windows can be derived mechanically by compiling the same contract with two different state-value sets and diffing (differing byte ranges = state windows, matching = fixed fingerprint). Next session: build that diff-based sync forKCC20Assetfirst (ctor args perkcc20.sil:[genesisIdentifier(64hex), genesisAmount, genesisIdentifierType, genesisIsMinter, maxCovIns, maxCovOuts]), replace its placeholder bytes + verify extraction yields the receipt fields, then wireclassify_kcc20_operationinto the node spend path (gather spent + successor receipts → classify → fillCovenantSpend.operation→dispatch_spend_hit). Thendeploy/status/logs/removeCLI + Phase 5.
The covenant operation — the last missing CovenantSpend field, long deferred as "needs per-pattern script semantics not in the repo." Turns out the KCC20 asset operation needs no compiled-script decode: it's a pure function of the asset state delta. New kasgraph_detectors::kcc20_operation module.
classify_kcc20_asset_operation(prior, next) -> Kcc20Operation—Mintwhentotal_supplyrises,Burnwhen it falls,RotateControllerwhen supply holds butcontroller_covenant_idchanges, elseTransfer. Grounded inkcc20.sil'scheckAmounts(non-minter transfers conserve supply; only minter branches change it) + the indexer'sKCC20Assetregistry state model (controller_covenant_id/total_supply/mint_nonce). Supply-delta is checked before the controller change so a mint that also re-points the controller still reads as a mint.Kcc20Operation::as_str()emits the exact strings theexamples/krc20mapping branches on:transfer/mint/burn/rotate_controller.Kcc20AssetState::from_payload(&Value)parses the hex field map aKCC20Assetdetector hit produces (payload_to_json→{field: hex}): big-endiantotal_supply(≤16Bu128) /mint_nonce(≤8Bu64), raw-hexcontroller_covenant_id(compared, never interpreted). Rejects missing / non-hex / oversized fields withKcc20DecodeError.- 9 unit tests (each operation, supply-precedence, payload parse + round-trip into classification, the three error paths) → detectors lib at 78; default
cargo test --workspace236; fmt/clippy clean. - NOT wired — honest gap: real classification needs the asset state read out of the on-chain state window, but the fingerprint registry still carries placeholder canonical bytes (
0xFE-prefixed), solocked_stateextraction isn't real yet. Wiring the classifier against placeholder extraction would classify fake state and mislead the spend mappings — so this is a pure core, likekrc20_ledgerwas. The remaining blocker narrows to real state extraction: thecargo xtask sync-opensilver-fingerprintstask that swaps placeholder bytes + state-window offsets for real OpenSilver compiled-script exports (sibling repo/home/void/openclaw/workspace2/OpenSilver,artifacts/manifests/). Once that lands: extract spent + successorKCC20Assetstates,classify_kcc20_asset_operation, fillCovenantSpend.operation, and swap the node's spend-detectioninfo!log for adispatch_spend_hitcall. Per-pattern operation semantics for the OpenSilver-core + ZK families are separate follow-ups; the KCC20 asset path is the first real decoder.
The committed-block audit already persisted each block's served_by, but the MultiRpcClient's per-fetch audit log (every fetch_block — recovery + hydration — records the endpoint that actually served it, failover-aware) was never drained or persisted: a latent unbounded-memory growth in long-running continuous mode, with that provenance lost on restart. This persists it from the live loop and bounds the log.
MultiRpcClient::drain_audit_log()(kasgraph-rpc) —std::mem::takeof the in-memoryVec<BlockAuditRecord>; returns the accumulated fetch records in order and clears them. Unit-tested (two mock fetches → drain returns both then empties; second drain empty).main::persist_drained_rpc_audit(store, client)drains the client log and writes each as anRpcBlockAuditRecord(the append-onlykasgraph_rpc_block_audittrail). Wired intorun_continuous_ingestion(each iteration, after recovery handling — recovery is the only fetch path in continuous mode, so this captures failover-aware recovery-fetch provenance and keeps the in-memory log bounded) and the bootstrap arm (afterbuild_notifications, which fetches via RPC). Subscription-delivered blocks aren't fetched, so their provenance stays the per-committed-blockserved_byrow — the two sources are complementary.- Verified: default
cargo test --workspace227 (+1 rpc drain test); node unit 64; fmt/clippy clean. The wiring (persist_drained_rpc_auditin the async loops) has unit-tested pieces (drain_audit_log;insert_rpc_block_auditis exercised by the persist-loop integration test) but no dedicated end-to-end test — driving it needs a mock/live RPC fetch to populate the client log, and the rpc mock server is test-private to that crate. - Next feature: the covenant
operationdecoder (blocked on OpenSilver/KCC20 script semantics) →CovenantSpentdispatch →deploy/status/logs/removeCLI + Phase 5.
Closes the honest gap the previous arc left: a #[sqlx::test] (integration_pg_persist::persist_loop_commits_poi_over_dispatched_entities, in main.rs behind integration-pg) drives the real apply_and_persist_notification loop end to end against live Postgres. It feeds a ChainNotification::BlockAdded finalized block whose one output carries the OpenSilverOwnable fingerprint bytes (from registry::all()), so block_from_rpc produces exactly one committed detector hit; a loaded WAT mapping (handleLock unconditionally emits Bond/b1 = {"n":7}) dispatches it. The test then asserts the persisted checkpoint (latest_poi_for_subgraph) equals an independent compute_poi(genesis, canonical_bytes_for_entities([Bond/b1])), that ingestion.prior_poi advanced to it, that the entity actually landed, and that kasgraph_poi::verify_poi_chain returns Valid for the one-block chain. Node integration suite now 3 tests; default cargo test --workspace still 226; fmt/clippy clean. Next feature: the covenant operation decoder (blocked on OpenSilver/KCC20 script semantics) → CovenantSpent dispatch → deploy/status/logs/remove CLI + Phase 5.
The node finally commits POI over each committed block's dispatched entity state instead of the pre-dispatch scaffold (KASGRAPH_BOOTSTRAP_ENTITY_BYTES / detector-hit canonical bytes). Done as the careful split I flagged: pure core + a contained persist-loop reorder, with the no-mapping path preserved byte-for-byte.
- Pure core (
mapping_host::canonical_bytes_for_entities) — maps the per-block dispatchedEntityVersionRecords ontokasgraph_poi::CanonicalEntityand delegates tocanonical_block_bytes(the encoder sorts, so input order is irrelevant; empty dispatch → well-defined empty-block bytes so the chain still advances). Plusmain::committed_poi_input(block, dispatched, mapping_loaded)selecting entity-state bytes (mapping) vs the block's detector-hit canonical bytes (no mapping). Both unit-tested. - The reorder — POI was computed in the pure transition (
IngestionState::apply_blockadvancedprior_poiand stampedCommittedBlockWrite.poi_hash). POI is now computed in the persist loop (apply_and_persist_notification) after dispatch:compute_poi(&ingestion.prior_poi, &committed_poi_input(&committed.block, &dispatched, mapping.is_some())), advancingingestion.prior_poiand writing the checkpoint last. The transition is now a pure block-promotion step (CommittedBlockWritedropped itspoi_hashfield). The audit + reorg-tracking rows still persist up front; only the POI checkpoint moved after dispatch. This also fixed a latent ordering subtlety: POI now always chains from the post-unwind re-anchoredprior_poi. - Behavior change scope: mapping-loaded subgraphs now hash real entity state (the point of 2.8). The no-mapping path is byte-identical to before (still
block.canonical_entity_bytes), so existing non-subgraph POI chains are unchanged. - Tests: the 3 transition-level POI tests were rewritten to the pure level (
committed_poi_inputchaining +reseed_prior_poianchor semantics, readingstate.prior_poi); +canonical_bytes_for_entities(order-independence/distinct-state/empty) and +committed_poi_input(mapping-vs-no-mapping). Verified: defaultcargo test --workspace226 (+3), node unit 64, fmt/clippy clean; store integration 8/8 + node integration 2/2 still green against live Postgres (no regression in the dispatch→persist path). - Not done (honest gap): no test drives
apply_and_persist_notificationend-to-end to assert the persisted POI equalscanonical_block_bytesover the dispatched rows — the pieces are each tested (committed_poi_input,compute_poiin the poi crate,insert_poi_checkpointin store integration) but the loop wiring itself has only unit-level coverage. A future end-to-end test would build a committed-blockChainNotification+Store+LoadedMapping, call the persist fn, andverify_poi_chainthe checkpoint. Next feature otherwise: the covenantoperationdecoder (still blocked on OpenSilver/KCC20 script semantics) →CovenantSpentdispatch →deploy/status/logs/removeCLI + Phase 5.
Extends the store integration suite (now 8 tests) to the most intricate untested SQL in the repo. Same integration-pg feature; test-only.
covenant_lineage_reorg_unwinds_and_reanchors_heads— the headline: verifiesunwind_covenant_lineage, the atomic 3-step reorg transaction (delete rows ≥ cutoff → drop heads with no surviving row → re-point each surviving head at its highest-seqsurvivor viaDISTINCT ON ... ORDER BY seq DESC, restoringcurrent_utxo/last_seen_daa/lineage_count). Sets up cid-A (3-step lineage, head re-anchors to the seq-1 survivor after dropping seq-2) and cid-B (single row at the cutoff, head orphaned and dropped). Head re-pointing is invisible to a SQL string test — only a live server shows it.covenant_lineage_population_and_spend_lifecycle— head/row population, thecovenant_lineage_row_existsreplay-idempotency key,fetch_covenant_lineage_head,covenant_lineage_continues(driven by a tracked covenant output of the spending tx), andrecord_covenant_spend(idempotent) +unwind_covenant_spends.legacy_krc721_journal_seq_exists_fetch_unwind— KRC-721 parity with the KRC-20 journal test: seq allocation, replay guard, record→fetch_krc721_legacy_ops_orderedround-trip (u64 token id overi64::MAX), DAA unwind.- A real constraint the integration test caught (string tests can't):
kasgraph_covenant_lineage_row.covenant_idhas a FK tokasgraph_covenant_lineage_head.covenant_id, so the head must be inserted before its rows — which matches how the node populates (persist_lineageopens/advances the head per hit, then appends the row). The unwind test was reordered head-first accordingly. - CI-safe (feature off by default → default
cargo test --workspacestill 223, fmt/clippy clean). Note:#[sqlx::test]does oneCREATE DATABASEper test; on a slow/contended disk those fsync-stall. The reliable recipe is a tmpfs +fsync=offthrowaway container and--test-threads=1(see the project memory / commands below). - Next: the store layer's reorg-critical SQL is now DB-verified. The big remaining unblocked feature is POI-after-dispatch (Phase 2.8) — see the note in the prior arc; do it pure-core-first (a tested
canonical_bytes_for_entitieshelper) before the riskier move of POI computation out of the pure transition into the persist loop.
The prior arc DB-verified the store layer; this verifies the node end of the wasm-dispatch loop — the sequence main.rs runs per committed block, which until now had only ever been exercised with an in-memory snapshot and no database. Added a feature-gated #[sqlx::test] module (integration_pg_tests) to crates/kasgraph-node/src/mapping_host.rs — in mapping_host.rs rather than a tests/ file because kasgraph-node is bin-only (no lib target a tests/ file could import). 2 tests, same integration-pg feature + sqlx dev-dep.
- What it exercises end to end against live Postgres:
LoadedMapping::load(dir)reading a real on-diskbuild/manifest.json+ wasm → build thestore_getread-set fromStore::snapshot_entities→dispatch_committed_hits→ persist each emitted record viaStore::upsert_entity_version→Store::unwind_entity_versionson reorg. The fixture writes a controlled WAT mapping as the wasm file (wasmtime'sModule::newparses WAT, so noasc/node toolchain in the Rust test; the example mappings' own wasm is already ABI-checked by the TSexamples-build.test.ts). The WAT'shandleLockemits itsBond/b1op only whenstore_gethits, so the two tests prove the Postgres-sourced snapshot actually steers the guest: (1) seedBond/b1@DAA50 → snapshot hit → dispatch@100 emits → persisted →latest_entity == {"n":7}→ reorgunwind_entity_versions(100)restores the DAA-50 survivor{"n":1}; (2) empty snapshot →store_getmiss → guest emits nothing → nothing persisted. - Verified CI-safe: default
cargo test --workspacestill 223 (the module is#[cfg(all(test, feature = "integration-pg"))]),build --all-targets/fmt/clippyclean in both feature states (the only node clippy warnings — incl.dispatch_spend_hit8-args — are pre-existing on origin/main). With the feature on: 63 node tests green. Run:DATABASE_URL=... cargo test -p kasgraph-node --features integration-pg. - Next: the dispatch loop is now DB-verified at both layers. Remaining gated work is unchanged: the covenant
operationdecoder (needs OpenSilver/KCC20 script semantics not in the repo) to unblockCovenantSpentdispatch, thendeploy/status/logs/removeCLI + Phase 5 hosted infra. A natural unblocked follow-up: extend this harness to the spend path once a decoder exists (dispatch_spend_hitis already written + dead-code), or to POI-after-dispatch (canonical_block_bytesover dispatched entities, the Phase 2.8 wiring item).
Until now every kasgraph-store query was only string-tested (the SQL builder's text, column order, ON CONFLICT) — no query had ever run against a live Postgres, so the migrations, the per-subgraph schema bootstrap, and the reorg-unwind semantics the whole BlockDAG model rests on were unverified end to end. A Postgres server is now reachable in this environment (Docker), so the gap is finally closeable. Added crates/kasgraph-store/tests/integration_pg.rs (#[sqlx::test], 5 tests) behind a new integration-pg cargo feature.
- Feature-gated so CI stays green. The file is
#![cfg(feature = "integration-pg")]; the defaultcargo test --workspace(and CI, which has no Postgres) compiles it to nothing — verified: default workspace still 223 cargo tests,cargo build --workspace --all-targetsclean,cargo fmt --check+cargo clippyclean in both feature states.#[sqlx::test]provisions a fresh DB per test and runs./migrationsinto it, reading the server fromDATABASE_URL. Run locally withDATABASE_URL=postgres://kasgraph:kasgraph@127.0.0.1:5434/kasgraph cargo test -p kasgraph-store --features integration-pg. - What the 5 tests verify against a real server: (1) all 6 migrations apply and the 9 base tables exist (
to_regclass); (2) the wasm-dispatch persistence core —ensure_subgraph_schema→upsert_entity_version(two DAAs) →latest_entity/snapshot_entitiesreturn the highest version →unwind_entity_versionsdrops the tip so the survivor is current → idempotent re-apply; (3) covenant-UTXOtrack→lookup(hit + outpoint miss)→unwind; (4) the legacy-KRC-20 journal —next_krc20_legacy_seqallocation, thekrc20_legacy_op_existsreplay guard, a full record→fetch_krc20_legacy_ops_orderedround-trip proving the write/read column mappings agree (incl. a u64 max-supply that overflowsi64, exercising the TEXT column choice), then DAA-cutoff unwind; (5) POI + committed-block reorg re-anchor —unwind_committed_blocks_for_subgraphdeletes the rolled-back POI rows + writes a reorg-audit row, andlatest_poi_for_subgraphre-anchors to the survivor. - Next, building on this: extend the same harness up to
kasgraph-node— an#[sqlx::test](or a node-level integration test on the same feature) that loads a built example wasm viaLoadedMapping, feeds a committedCovenantLockeddetector hit throughdispatch_committed_hits, and asserts the emitted entity row lands inentity_versionsand is unwound on reorg. That is the node end of the loop that's been built but never DB-verified, and it's now unblocked (commit429dfee/d52614dwiring + this store coverage). The two still-blocked items are unchanged: the covenantoperationdecoder (needs OpenSilver/KCC20 script semantics not in the repo) and Phase 5 hosted infra.
kasgraph build now rejects a manifest that fails validateManifest (prior arc), but nothing guarded that the kasgraph init scaffold itself produces a manifest the gate accepts — a fresh init→build could silently break if the template drifted. This adds the regression test. Commit 2760a84 (tests/cli.test.ts). Test-only; pure.
scaffolds a manifest that passes the build-time validator— runsrunInit, parses the generatedsubgraph.yaml, assertsvalidateManifest(...)returns[]. The init template (kaspa-testnet-12,covenant_id,source: {ids: []},mapping.kind: typescript,entities: [Bond], two handlers) already satisfies the contract, so this pins it there. cli.test.ts at 17 → JS suite at 186.- Next honest, unblocked candidates (no Postgres/network): the docs/references are rich but nothing asserts the examples match what codegen/build actually consume beyond compilation; another option is small validator hardening (e.g. reject duplicate data-source
names, or a handlereventdeclared twice within one source) — but only if it reflects a real downstream constraint, not an invented rule. The substantive deployment-unblocking work (load built wasm inkasgraph-node, dispatch events, thendeploy/status/logs/remove+ Phase 5) remains Postgres/infra-gated.
The @kasgraph/sdk shipped only manifest types, and both kasgraph build and codegen parsed subgraph.yaml loosely (all fields optional) — so a malformed manifest produced a confusing downstream error or a node that chokes after deploy, never a precise up-front rejection. This adds the missing runtime validator and wires it into the build gate. Commit 5e6315f. Pure + unit-tested; no Postgres/network.
validateManifest(value: unknown) -> ManifestIssue[](sdk/src/index.ts) — checks a parsed manifest against the documentedSubgraphManifestcontract: top-level object;specVersion/namenon-empty strings;schema.filenon-empty;dataSourcesa non-empty array; each source'sname,network(closed enumkaspa-mainnet|kaspa-testnet-12|kaspa-devnet),kind(closed enum, the 5 kinds),source(object), optional numericstartBlock, andmapping(kind:'typescript', non-emptyfile,entities[]of strings,handlers[]of{event,handler}). Returns one{path, message}per problem with a dotted/indexed locator (e.g.dataSources[0].mapping.kind); empty array = valid. Also exportsDATA_SOURCE_KINDS,SUBGRAPH_NETWORKS,ManifestIssue. Deliberately does NOT validate kind-specificsourcecontents or on-disk file existence — those stay the caller's concern (build resolves files; codegen reads selectors).- Wired into
kasgraph build(cli/src/build.ts) right after the YAML parse: ifvalidateManifestreturns issues, build prints each<path>: <message>and exits 65 (EX_DATAERR) before any compilation. Build-test fixtures updated to the canonical manifest form (kaspa-*networks,mapping.kind: typescript,entities) that the six shipped examples already use. - +10 SDK tests (SDK at 16) + 1 build test → JS suite at 185 green. SDK tests cover the canonical pass, the non-object reject, each malformed field at its expected path, and a sweep asserting all six
examples/*/subgraph.yamlvalidate clean. The build test feeds a contract-violating manifest (bad network + missing mapping) and asserts exit 65 with the locators in stderr. - Considered and rejected: wiring
validateManifestintokasgraph codegentoo. Codegen only consumes a manifest subset (name,dataSources[].kind,source.ids,mapping.handlers) and its many unit fixtures are intentionally minimal (barename: x+ handlers); forcing the full contract there would break ~10+ fixtures (or require bloating them) for no real gain, since the build gate already enforces the contract before deploy. Left codegen's loose parse as-is.
The store now reads journal rows back in replay order (prior arc), and Krc*Ledger::replay consumes an inscription stream — but nothing converted a stored row back into an inscription. This adds the inverse of the inscription parser, closing the rebuild loop except for the Postgres call site. Commit a1e6617 (kasgraph-node::legacy_ledger). Pure + unit-tested; #[allow(dead_code)] until the call site lands.
krc20_inscription_from_record/krc721_inscription_from_record— map a journaled op row's columns (opselects the variant;amount/token_id/recipient/metadata_uri/max_supply/mint_limitfill it;tick/tick_rawcarried through) back into theKrc*Inscriptionreplayconsumes. ReturnResult<_, LegacyReconstructError>: an unknownop, a missing required column, or a non-decimal-u64 numeric column is an error, never a silently-fabricated op (the columns were written from aValidparse, so a well-formed journal always reconstructs cleanly — the Result guards against a future-bug-corrupted row).replay_krc20_from_records/replay_krc721_from_records— reconstruct each row in a fetched slice and fold it throughKrc*Ledger::replay. The row'ssenderis the already-resolved op sender, so the rebuild path needs no address resolution (live ingestion of new ops still does — the shared blocker below).- 11 unit tests → kasgraph-node at 61. Each op reconstructs to its inscription; tick/tick_raw preserved; a u64 amount beyond
i64::MAXround-trips (the reason amounts are TEXT); the three error paths (unknown op, missing column, non-decimal); end-to-end replay rebuilding KRC-20 balances (deploy→mint→transfer, sender 300 / bob 200 / minted 500) and KRC-721 ownership (deploy→mint→transfer, id 1 owned by bob) straight from row streams. - Remaining to close the loop (Postgres-gated): the startup/reorg call site only. In
kasgraph-node, on startup and afterunwind_krc*_legacy_ledger, callfetch_krc*_legacy_ops_ordered(subgraph)→replay_krc*_from_recordsto rebuild the in-memory ledger. Every piece it needs is now pure + landed (parser, ledger, journal write, ordered read, reconstruction, replay); only wiring them at the real call site (and live-op ingestion via the sender resolver) remains, and that needs Postgres to verify end-to-end.
Krc20Ledger::replay / Krc721Ledger::replay (prior arc) rebuild a ledger from an ordered accepted-op stream, but nothing read that stream back out of the journal — the journal could be written (record_krc*_legacy_op) and unwound (unwind_krc*_legacy_ledger) but not fetched. This adds the read side. Commit 479f23e (kasgraph-store). The SQL builders are pure + unit-tested as with every other store query; executing the reads still needs Postgres.
fetch_krc20_legacy_ops_ordered(subgraph)/fetch_krc721_legacy_ops_ordered(subgraph)— return a subgraph's accepted ops asVec<Krc*LegacyOpRecord>ordered by(accepting_daa_score, tick, seq). A tick's intra-stream order is its monotonicseq; inter-tick order is irrelevant since ticks don't interact; so the DAA→tick→seq order is deterministic and preserves each tick's acceptance order — exactly whatreplayneeds. Thesubgraphis supplied by the caller (not re-read), so the row tuple is twelve columns.fetch_krc20/721_legacy_ops_ordered_sql()+ 2 SQL-builder tests → kasgraph-store at 19. Each test pins the select column order to the method's row-tuple mapping and theORDER BY. A sharedLegacyOpRowtype alias (the two reads have identical SQL-type shapes, differing only in whichOption<String>slots carry which field) keeps both 12-columnquery_ascalls clippy-clean (notype_complexity).- Remaining (Postgres+address-gated): node wiring. Two pieces still missing to close the legacy-ledger rebuild loop: (1) a pure record→inscription reconstruction (inverse of
parse_krc*_inscription, reading the storedop/token_id/recipient/uri/maxcolumns back into aKrc*Inscription) so the fetched rows can feedreplay; (2) the startup/reorg call site inkasgraph-node(afterunwind_krc*_legacy_ledger, fetch the survivors, reconstruct,replay). The reconstruction is pure and could land ahead of the call site, but verifying the whole loop needs Postgres. Live ingestion of new ops still needs the sender (input→address) resolver — the shared blocker below.
The kasgraph-poi module doc states its purpose is to "allow third parties to verify indexer correctness," but only the compute side existed (compute_poi / canonical_block_bytes / canonical_json / poi_hex). The verify side a third party actually runs was missing. This adds it. Commit f7c34d5 (kasgraph-poi). Fully pure + unit-tested — no Postgres.
verify_poi_chain(&[PoiCheckpoint]) -> Result<PoiVerification, PoiError>— recomputes a published chain from genesis ([0u8; 32]), checking eachPoiCheckpoint { canonical_entity_bytes, expected_poi }. ReturnsPoiVerification::Valid { final_poi }when every published POI matches recomputation, orDiverged { index, expected, recomputed }pinpointing the first block that doesn't. Empty chain →Validat the genesis prior.poi_from_hex(&str) -> Result<PoiHash, PoiError>— inverse ofpoi_hex, for reading checkpoints off a status page / another indexer's published chain. Rejects non-hex and wrong-length input withPoiError::InvalidHex(the new variant alongsideEmptyEntityBytes).- +7 tests → POI at 17: hex round-trip + bad-hex + wrong-length rejection, empty-chain-verifies, honest-chain-verifies, tampered-checkpoint-diverges-at-its-index, and an end-to-end test running entities →
canonical_block_bytes→ chain →verify_poi_chain == Valid. - Remaining (Postgres-gated): node wiring. Same gate as the canonical-encoding arc below — feed
verify_poi_chainfrom stored committed-block POIs + recomputed canonical bytes over dispatched entities. The verifier itself is complete and pure; what's missing is a real chain of published POIs to verify, which only exists once the node computes POI over real dispatched entity state (post-dispatch, Postgres-backed).
Both legacy ledgers' docs claimed "state is a pure function of the accepted op stream" (the basis of the reorg model) but had no replay entry point or test pinning it. This adds both. Commit d593819 (kasgraph-detectors). Fully pure + unit-tested now — no Postgres or address resolution needed (senders are already-resolved strings in the journaled stream).
Krc20Ledger::replay/Krc721Ledger::replay—replay<'a, I: IntoIterator<Item = (&'a Inscription, &'a str)>>(ops) -> Selffoldsapplyover an ordered accepted-op stream. This is exactly what the node calls afterunwind_krc*_legacy_ledger(which deletes the reorged DAA suffix) and on startup: rebuild the in-memory ledger by replaying surviving journal rows in acceptance order.- Reorg-survivor property test (each ledger) — builds a DAA-tagged op stream, filters to rows below a cutoff (modelling the unwind delete), replays the survivors, and asserts the rebuilt state equals the surviving prefix (transfer/mint dropped, earlier ops intact). Plus a
replay == incremental applysanity test. +4 → detectors at 69. - Why every survivor re-applies as
Accepted: a reorg deletes a DAA suffix, and a tick/collection's deploy always precedes its mints/transfers, so any surviving prefix is self-consistent — the journal never contains an orphaned mint whose deploy was cut. - Remaining for the rebuild path (Postgres-gated): the node glue + the store fetch. A
fetch_krc20_legacy_ops_ordered/fetch_krc721_legacy_ops_orderedstore read (order by(accepting_daa_score, tick, seq); intra-tick order preserved, inter-tick order is irrelevant since ticks don't interact), and a node-side record→inscription reconstruction (inverse of the parser, reading the storedop/token_id/recipient/uri/maxcolumns) that feedsreplay. Both are pure-ish but the fetch's row-mapping (and the actual reorg/startup call site) need Postgres to exercise, and live ingestion of new ops still needs the sender (input→address) resolver — the shared blocker below.
The store-side durable journal behind the pure Krc721Ledger, the NFT parallel of kasgraph_krc20_legacy_ledger. Commit 4a35e11 (kasgraph-store). SQL-builder/struct tested only — end-to-end needs Postgres (the established discipline for this crate).
kasgraph_krc721_legacy_ledger(new migration20260529130000) — records every accepted NFT op (deploy/mint/transfer/burn). Legacy KRC-721 state is a pure function of the accepted op stream, so per-token ownership is rebuilt by replaying these rows in acceptance order; reorg deletes rows at/above the reorged DAA and re-replays survivors. Keyed globally by(tick, accepting_block_hash, seq)(a collection tick is global across the Kasplex view — same global-keying choice as the lineage / legacy-KRC-20 tables); thesubgraphcolumn scopes only reorg unwind.UNIQUE (tx_hash)is the replay idempotency key.token_id/max_supplyare TEXT decimal strings, not BIGINT — KRC-721 ids and collection sizes are u64 and can exceedi64::MAX(same rationale as legacy-KRC-20 amounts).Krc721LegacyOpRecord+ fourStoremethods mirroring the KRC-20 set:record_krc721_legacy_op(ON CONFLICT (tx_hash) DO NOTHING),krc721_legacy_op_exists,next_krc721_legacy_seq(per-tickMAX(seq)+1),unwind_krc721_legacy_ledger. 2 SQL-builder tests + migrator bumped to 6 → kasgraph-store at 17.- Design note: the reference (
:74) nameskasgraph_krc721_legacy_token(current owner) +_transfer(history) head tables. Those are a query-layer projection over this journal — they land with the GraphQL/MCP consumer, not the indexing path. The journal is the single durable replayable record, exactly as the KRC-20 slice kept balances in the in-memory ledger rather than a store head table. Symmetry across the two legacy protocols. - Next slice (the last legacy-KRC-721 piece, Postgres-gated): node wiring. Shares the sender-resolution blocker with legacy KRC-20: scan
committed.block.payloads,parse_krc721_inscription, resolve sender (= first input's address, needs input→address resolution the wire model lacks),krc721_legacy_op_exists→next_krc721_legacy_seq→Krc721Ledger::apply; onAccepted,record_krc721_legacy_op. On reorg,unwind_krc721_legacy_ledger+ re-replay. Afetch_krc721_legacy_ops_orderedread lands with this wiring.
The NFT parallel to the four legacy-KRC-20 slices below. Both pure, storage-agnostic, fully unit-tested without Postgres — the same "pure core first" discipline. Commit 7f30d30 (kasgraph-detectors).
kasgraph-detectors::krc721—parse_krc721_inscription(payload) -> Krc721Parsefor the Kasplex-era{"p":"krc-721","op":...}payload envelope (perKRC20_KRC721_REFERENCE.md:58-74).Krc721Optagged enum:deploy=max,mint=id+uri,transfer=id+to,burn=id. Strict decimal-u64 formax/id; lowercase-ASCII tick normalization withtick_rawpreserved. Three-wayNotKrc721/Malformed(reason)/Validoutcome (mirrorsKrc20Parse). Mint requires a non-emptyuri; transfer requires a non-emptyto.kasgraph-detectors::krc721_ledger—Krc721Ledger::apply(&inscription, sender) -> ApplyOutcomere-derives per-token ownership over aBTreeMap<tick, CollectionState>.CollectionState=max+owners: BTreeMap<id, owner>(live tokens) +minted: BTreeSet<id>(every id ever minted). Rules: deploy first-writer-wins; mint requiresid < maxAND the id has never been minted (uniqueness is permanent — a burned id stays inmintedso it can never be re-minted); transfer/burn require the sender to currently own the token. 21 unit tests across both modules → detectors at 65.- Next slices (Postgres-gated): store tables + node wiring.
kasgraph_krc721_legacy_tokenkeyed(tick, token_id)with an owner column + akasgraph_krc721_legacy_transferhistory table (KRC20_KRC721_REFERENCE.md:74). Then node wiring: scan payloads,parse_krc721_inscription, resolve sender (= first input's address — the same input→address sub-dependency the KRC-20 wiring needs),Krc721Ledger::apply, persist accepted ops + reorg replay. Shares the sender-resolution blocker with legacy KRC-20.
The POI scaffold computed blake2b-256(prior_poi || bytes) but left the "sorted canonical" entity-state encoding undefined (it accepted pre-canonicalized bytes). This defines it — the missing Phase 2.8 piece. Pure + fully unit-tested. Commit 1c992e5 (kasgraph-poi).
canonical_block_bytes(&[CanonicalEntity]) -> Vec<u8>— the deterministic input tocompute_poi. Two honest indexers that processed the same committed block must produce byte-identical bytes here, so their POI chains agree and a third party can verify either. Pins every degree of freedom: entities sorted by(entity_type, entity_id)(collection order irrelevant); each JSON state canonicalized viacanonical_json(object keys recursively sorted, compact — independent of Postgres JSONB key ordering; array order preserved as semantic); every field length-prefixed (u32-le) so adjacent fields can't be reinterpreted as a different split; entity count prefixed so an empty block still hashes to a well-defined non-empty value (the chain advances every block).canonical_json(&Value)+CanonicalEntitytype. 7 new tests (key-order independence, array-order preservation, input-order independence, length-prefix disambiguation, empty-block count prefix, distinct-state distinctness) → kasgraph-poi at 10.serde_jsonadded to the crate.- Next slice (Postgres-gated): node wiring. The node currently feeds
BootstrapBlock.canonical_entity_bytesfrom an env scaffold (KASGRAPH_BOOTSTRAP_ENTITY_BYTES). Replace it withcanonical_block_bytesover each committed block's dispatched entities. The catch: those entities come fromdispatch_committed_hitsseeded byStore::snapshot_entities(Postgres), and POI is currently computed inblock_from_rpcbefore dispatch — so wiring real canonical bytes means computing POI after dispatch, which needs a real DB to verify end-to-end. Pairs naturally with thesqlx::testcoverage item.
Fourth legacy-KRC-20 slice: the store-side durable journal behind the pure Krc20Ledger. Commit d629b02 (kasgraph-store). SQL-builder/struct tested only — end-to-end needs Postgres (the established discipline for this crate).
kasgraph_krc20_legacy_ledger(new migration20260529120000) — the journal of accepted Kasplex-era inscription ops. Legacy KRC-20 state is a pure function of the accepted op stream, so the ledger is reconstructed by replaying these rows in acceptance order (KRC20_KRC721_REFERENCE.md:54). Keyed globally by(tick, accepting_block_hash, seq)(a tick is global across the Kasplex view — same global-keying choice as the lineage tables); thesubgraphcolumn scopes only reorg unwind.UNIQUE (tx_hash)is the replay idempotency key (one inscription per tx payload). Amounts (amount/max_supply/mint_limit) are TEXT decimal strings, not BIGINT — KRC-20 amounts are u64 and can exceedi64::MAX, so BIGINT would silently corrupt large values; replay re-parses via the envelope parser's strict decimal-u64 path.Krc20LegacyOpRecord+ fourStoremethods:record_krc20_legacy_op(idempotent insert,ON CONFLICT (tx_hash) DO NOTHING),krc20_legacy_op_exists(tx_hash)(pre-seq replay guard, mirrorscovenant_lineage_row_exists),next_krc20_legacy_seq(tick)(per-tickMAX(seq)+1,0for a fresh tick),unwind_krc20_legacy_ledger(subgraph, from_daa)(subgraph + DAA-cutoff delete). 2 SQL-builder tests + migrator bumped to 5 → kasgraph-store at 15.- Next slice (the last legacy-KRC-20 piece, Postgres-gated): node wiring. In the committed loop, scan
committed.block.payloads,parse_krc20_inscription, resolve the sender (= first input's address — needs prior-outputscriptPublicKey→ address resolution, which the wire model doesn't carry yet, so that's a sub-dependency), callkrc20_legacy_op_exists→next_krc20_legacy_seq→Krc20Ledger::apply; onAccepted,record_krc20_legacy_op. On reorg,unwind_krc20_legacy_ledgerthen re-replay the surviving stream to rebuild the in-memory ledger. Afetch_krc20_legacy_ops_orderedread method lands with this wiring (the consumer that needs it).
Third legacy-KRC-20 slice: a pure (no-Postgres) state machine that applies the Kasplex inscription rules over a stream of (Krc20Inscription, sender). Commit 83fd12f (kasgraph-detectors::krc20_ledger).
Krc20Ledger—apply(&inscription, sender) -> ApplyOutcomedispatching deploy/mint/transfer/burn over aBTreeMap<tick, TokenState>. Rules: deploy first-writer-wins (a second deploy of the same tick isRejected("tick already deployed")); mint requiresamt <= limANDminted + amt <= max(rejected wholesale, never partial), then credits sender + bumpsminted; transfer requiressender_balance >= amt; burn requiressender_balance >= amt, then decrements both balance andminted(saturating) so thesupply == sum(balances)invariant holds. Zero-balance entries pruned on debit.TokenStateexposesmax/lim/minted/balances;ApplyOutcomeisAccepted|Rejected(&'static str). 11 unit tests cover each rule, each rejection path, and the supply invariant → detectors at 44.- Sender = the tx's first input address; resolving it at wire time still needs input→address resolution (prior-output
scriptPublicKey→ address), deferred to node wiring. - Next slices (in order, both need Postgres to verify): (1) a dedicated
kasgraph_krc20_legacy_ledgerstore table keyed(tick, accepting_block_hash, seq)with reverse-acceptance-order reorg unwind — the lineage-row model does NOT apply (KRC20_KRC721_REFERENCE.md:54). (2) node wiring: scancommitted.block.payloads,parse_krc20_inscription, resolve sender,Krc20Ledger::apply, persist accepted ops + reorg-unwind.
Second legacy-KRC-20 slice: the inscription parser (below) had nothing to read because the wire model carried only outputs + inputs. Commit 9394e8c (kasgraph-rpc, with literal-site updates in kasgraph-node).
IngestedTransactionPayload(tx_hash+ decodedpayloadbytes) +IngestedBlock.payloads, populated by a new resilientextract_transaction_payloads: only transactions with a non-empty, hex-decodablepayloadfield appear, in transaction order (so the ledger applies ops in order); the common empty-payload case is omitted to keep the model lean. Mirrors the input/output extractors' skip-don't-fail shape.tx_hashlets the ledger associate the op with the tx (and, via the block's inputs, the sender). 2 parse tests (extraction/order, non-hex skip) → rpc at 32.- Note:
block_to_rpcsetspayloadsempty for now (theBootstrapBlockscaffold path doesn't carry payloads, same asinputswere initially). Payloads flow from the RPC fetch path (parse_block_value), which is what the ledger consumes. - Next slices (in order): (1) a pure ledger acceptance state machine in
kasgraph-detectors(or a new module) applying the Kasplex rules over a stream of(Krc20Inscription, sender)— deploy first-writer-wins; mintcumulative_minted + amt <= max && amt <= lim; transfersender_balance >= amt; burn decrements both — returning accept/reject per op. This is unit-testable WITHOUT Postgres (pure state transition), so it's the next "pure core" slice. Sender = the tx's first input address, so wiring later needs input→address resolution (the wire model has the input outpoints; resolving them to addresses needs the prior output's scriptPublicKey → address, a separate concern). (2) a dedicatedkasgraph_krc20_legacy_ledgerstore table keyed on(tick, accepting_block_hash, seq)with reverse-acceptance-order reorg unwind (the lineage-row model does NOT apply —KRC20_KRC721_REFERENCE.md:54). (3) node wiring: scancommitted.block.payloads, parse, apply to the ledger. Steps 2-3 need Postgres to verify end-to-end.
Legacy (Kasplex-era) KRC-20 operations are JSON inscriptions in the transaction payload field (protocol-observable), not covenant scripts — the unblocked alternative to the native operation decoder (blocked on OpenSilver script semantics). Commit ebed8bf (kasgraph-detectors).
kasgraph-detectors::krc20— pureparse_krc20_inscription(payload: &[u8]) -> Krc20Parsefor the{"p":"krc-20","op":"deploy|mint|transfer|burn",...}envelope (perKRC20_KRC721_REFERENCE.md:21-56). Three-way outcome:NotKrc20(not a KRC-20 payload — ignored silently, the common case),Malformed(reason)(claimsp == "krc-20"but violates a precondition — dropped, caller logs at debug),Valid(Krc20Inscription). Amounts are strict decimal u64 strings (reject signs/decimals/hex/>u64::MAX); ticks normalize to lowercase ASCII withtick_rawpreserved.Krc20Opis a tagged enum carrying per-op fields. 10 unit tests → detectors at 33.
Follow-up hardening on the population slice below (commit 566c3c1, kasgraph-node + kasgraph-store).
- Bug: reconnect re-delivery can re-present an already-ingested hit (the reason
track_covenant_utxo/record_covenant_spendare idempotent).persist_lineagewas not — replaying a hit re-fetched the advanced head and appended a phantom transition at the nextseq, inflatinglineage_countand the history. - Fix: a covenant output is exactly one lineage step, so
(covenant_id, tx_hash, output_index)is a sound idempotency key.persist_lineagenow returns early when that step already exists (Store::covenant_lineage_row_exists), and aUNIQUE (covenant_id, tx_hash, output_index)constraint enforces it at the schema level (and indexes the lookup). 145 cargo + 173 TS tests green; fmt + clippy clean.
The covenant lineage tables (kasgraph_covenant_lineage_head / _row) were created in the first store migration but never written; now that covenant_id is real (prior arc), the node populates them. One commit (665124c, kasgraph-node + kasgraph-store).
- classification —
assign_covenant_idbecameclassify_lineage, returning aLineageClass::{Genesis(id), Transition(id)}verdict so the node knows whether a hit opens or continues a lineage (the id alone hid that distinction). - population (
persist_lineage, gated on a loaded mapping) — each detector hit opens (genesis) or advances (transition) the head and appends the next per-transition row.seq== the prior head'slineage_count(genesis → seq 0/count 1; each transition increments by one, perKIP20_COVENANT_ID_QUERIES.md:109). Factored into a purenext_lineage_step(prior_count) -> (seq, count)with 2 unit tests.state_bytescarries the detector payload as JSON for state replay. - schema — lineage is keyed globally by
covenant_id(matching the MCPget_covenant_lineage+ GraphQL consumers, which take no subgraph filter). Added asubgraphcolumn to both tables (+ a(subgraph, daa_score DESC)index) used only to scope reorg unwind; amended the existing migration since there's no Postgres to migrate. NewStore::fetch_covenant_lineage_head(covenant_id). - reorg safety — new
Store::unwind_covenant_lineage(subgraph, from_daa)runs atomically: delete this subgraph's rows at/above the cutoff, drop heads with no surviving row, then re-point each surviving head at its highest-seqsurviving row (restoringcurrent_utxo/last_seen_daa/lineage_count). Wired into the node reorg path alongside the utxo/spend unwinds, so the lineage view never exposes a transition the surviving chain dropped. - Net: the
get_covenant_lineageMCP tool + GraphQLlineageconnection now have real data to read once Postgres is present. 145 workspace tests green; fmt + clippy clean (only pre-existing drift). Population/unwind are DB-dependent so they need Postgres to exercise end-to-end (the pure seq arithmetic is unit-tested). - Remaining — still the
operationdecoder (unchanged from below): per-pattern covenant script semantics not in the repo. Plus real-Postgressqlx::testcoverage anddeploy/status/logs/removeCLI + Phase 5.
KasGraph now computes covenant ids and the lineage model itself (RPC doesn't expose them; KASPA_RPC_REFERENCE.md:443 delegates this to the indexer). Three commits.
- covenant-id derivation (
ff9d26a,kasgraph-detectors) —genesis_covenant_id(tx, output)= domain-separated (kasgraph.covenant_id.v1), versioned blake2b-256 over the genesis outpoint, hex-encoded (64 chars). Established once at genesis, inherited unchanged across the lineage. Pure + 4 unit tests (determinism, outpoint/index distinctness, domain separation) → detectors at 23. Design commitment: this defines KasGraph's covenant_id; if a real KIP-20 consensus id ever appears via RPC, bump the domain version and migrate. - lineage assignment in the node (
4ea7fc6,kasgraph-node) —assign_covenant_id(store, subgraph, hit, inputs)classifies each hit: if its tx consumes a tracked covenant UTXO → transition (inherit the predecessor's id); else → genesis (fresh id). The assigned id flows to both the detected-pattern row and the tracked UTXO, so the previously-Nonecovenant_id is now real and spends inherit it. Gated on a loaded mapping (no-mapping path unchanged). - successor resolution (
edcb6b7,kasgraph-node+kasgraph-store) —successorCovenantIdon a detected spend = the spent covenant's id when the spending tx produced a tracked same-id covenant output (Store::covenant_lineage_continues, anEXISTSovercovenant_utxos), elseNone. Correct within a block because the lock loop (which tracks the continuation output) precedes the spend loop, and a spend + its successor share a transaction. Newsuccessor_covenant_idcolumn oncovenant_spends. - Net: the
CovenantSpendenvelope is now honestly complete exceptoperation. 143 workspace tests green; fmt clean. The genesis-vs-transition path needs Postgres to exercise end-to-end (unit tests cover the pure derivation; the classification reads the DB). - Remaining — the operation decoder only.
operation(transfer/mint/burn/redeem) needs per-pattern covenant script semantics not in the repo (OpenSilver compiled bytes are placeholders; KCC20 operation decode unbuilt). A coarse continue/terminate binary is derivable from successor presence, but the example mappings branch on specific strings, so a coarse value would mislead — deferred. Once a real operation is derivable, fill it on theCovenantSpendRecord, build theCovenantSpendenvelope from the now-complete row, resolve theCovenantSpenthandler via the descriptor (keyed ondetector_kind), seed prior state from the matchedlocked_state, and calldispatch_spend_hit(412de54) in place of theinfo!log. Plus: real-Postgressqlx::testcoverage, anddeploy/status/logs/removeCLI + Phase 5.
Detected spends are now durable, not just logged (commit 50869a4, kasgraph-store + kasgraph-node).
- New per-subgraph
covenant_spendstable (PK(spending_tx_hash, previous_tx_hash, previous_output_index);block_daa_score,detector_kind,covenant_id,spent_value_sompi).CovenantSpendRecord+record_covenant_spend(idempotent on the spending input) +unwind_covenant_spends. - Two-table reorg model:
covenant_utxosis the immutable lock-time set (unwound when the lock block reorgs);covenant_spendsis keyed on the spending block's DAA (unwound when the spend block reorgs). So a reorg that drops only the spend block rolls back the spend row and restores spend-detectability for its outpoints, while the earlier UTXO row survives. Both unwinds run in the node's reorg path at the same cutoff. - Every persisted column is protocol-observable at detection time, so the row is honest today —
operation/successorCovenantIdare deliberately absent (they need the spend-tx decoder). 2 SQL-builder tests → kasgraph-store at 12; 139 workspace tests green; fmt clean (detectors drift untouched). - Remaining for dispatch unchanged: the spend-tx decoder for
operation+successorCovenantId. With spends now persisted, a future option is to dispatchCovenantSpentdirectly off thecovenant_spendsrow once the decoder fills those two fields (spentValueSompiand the rest are already there).
Honest, protocol-observable groundwork for the CovenantSpend envelope (commit 2dcf7b6, kasgraph-store + kasgraph-node).
- The locked output's value is observable at lock time, so recording it lets a detected spend report
spentValueSompihonestly — no spend-tx decoder needed for this field. Addedvalue_sompi BIGINT NOT NULLto thecovenant_utxostable,CovenantUtxoRecord, andCovenantUtxoMatch. - The node derives
value_sompifrom the locking block's output whose(tx_hash, output_index)matches theCovenantLockedhit (falls back to 0 if absent), and surfaces it on the spend-detectioninfo!log. - 137 workspace tests green;
cargo fmtclean (except the pre-existing detectors drift, intentionally untouched). - Remaining for
CovenantSpentdispatch now narrows to two fields:operationandsuccessorCovenantId, which genuinely need a spend-transaction decoder (read the spending tx + KIP-20 lineage head). Once those are honestly derivable, build theCovenantSpendenvelope{ operation, spentValueSompi: <from the match>, successorCovenantId }, resolve theCovenantSpenthandler via the descriptor keyed on the matcheddetector_kind, seed prior state from the matchedlocked_state, and calldispatch_spend_hit(412de54) in place of theinfo!log.
Spend detection is now wired end-to-end through the node; spend dispatch stays deferred behind a missing decoder (see below). Two commits this arc.
- Store layer (commit
4e823d0,kasgraph-store) — per-subgraphcovenant_utxostable (PK(tx_hash, output_index);block_daa_score,detector_kind,covenant_id,locked_stateJSONB) created byensure_subgraph_schema.CovenantUtxoRecord/CovenantUtxoMatchvalue types +track_covenant_utxo(upsert on outpoint, idempotent for replay),lookup_covenant_utxo(subgraph, tx_hash, output_index),unwind_covenant_utxos(subgraph, from_daa). Three injection-safe SQL builders (validatedSubgraphIdschema name). 3 unit tests → kasgraph-store at 10. - Node wiring (commit
6930ebd,kasgraph-node) — threadsinputsthroughBootstrapBlock/block_from_rpc/block_to_rpc. On eachCovenantLockedhit (gated onKASGRAPH_SUBGRAPH_DIRviamapping.is_some()), persists aCovenantUtxoRecord(locked_state= the hit payload). After applying a block, scans its inputs against the tracker vialookup_covenant_utxo— placed outside the!hits.is_empty()guard so a spend in a block that locks no new covenant is still caught — and emits an honestinfo!on a match. Reorg path unwindscovenant_utxosalongside entity versions. +1 threading assertion → node at 49; 137 workspace tests green. Same commit folds incargo fmtcleanup of previously-committed rpc/store/mapping_host code. - Why dispatch is still deferred (the one remaining piece):
CovenantSpentexample mappings branch onspend.operation/successorCovenantId, and there is no spend-transaction decoder yet to derive those (or the spent value) honestly. Feeding a placeholder would corrupt every spend mapping, violating the project's honesty principle. So the matching is wired and logged butdispatch_spend_hit(412de54) stays ready/dead_code. - Next slice — the spend-tx decoder. Build a decoder that reads the spending tx (+ KIP-20 lineage head) to produce
operation/successorCovenantId/spentValueSompi. Then in the node's input-scan block, replace theinfo!log with: build theCovenantSpendenvelope from the decoded fields, resolve theCovenantSpenthandler via the descriptor (keyed on the matched lock'sdetector_kind), seed prior state from the matchedlocked_state, and calldispatch_spend_hit. All other plumbing is done. Then: real-Postgressqlx::testcoverage, anddeploy/status/logs/removeCLI + Phase 5.
The first foundational slice toward wiring spend dispatch is landed (commit d9ec50c, kasgraph-rpc). The block wire model carried transaction outputs only; it now carries inputs too.
IngestedTransactionInput(spending_tx_hash+previous_tx_hash+previous_output_index) and aninputs: Vec<…>field onIngestedBlock, populated by a new resilientextract_transaction_inputsthat walks each tx'sinputs[].previousOutpoint. Mirrors the output extractor's resilience: an input without a parseablepreviousOutpointis skipped; coinbase inputs (zero previous tx id) are kept but inert (spend matching never tracks a zero-hash UTXO).block_to_rpcsetsinputsempty for now —BootstrapBlockdoesn't carry inputs yet (next sub-slice).- 3 parse tests → kasgraph-rpc at 30; workspace green, warning-clean.
- Next sub-slices to make spends actually dispatch (in order): (1) add
inputstoBootstrapBlock+ populate it inblock_from_rpcso inputs reach the committed loop; (2) a covenant-UTXO tracker — when aCovenantLockeddetector hit fires, its output(tx_hash, output_index)is a covenant UTXO; persist a(tx_hash, output_index) → {kind, covenant_id, locked_state_payload}lookup (new store table + SQL builders, unit-testable likeentity_versions; thekasgraph_covenant_lineage_*tables are related but track lineage heads, not the outpoint→covenant lookup spend matching needs); (3) in the committed loop, for each block input look up(previous_tx_hash, previous_output_index)against the tracker — a hit is a spend; build theCovenantSpendenvelope (operation/spentValueSompi from the spend tx, successorCovenantId from lineage), resolve theCovenantSpenthandler via the descriptor (keyed on the locked covenant's kind), and calldispatch_spend_hit. Needs Postgres to verify end-to-end.
The deterministic core for dispatching covenant spends is landed (commit 412de54, kasgraph-node::mapping_host), symmetric to the locked path and ahead of the input-scanning wire change that will feed it — same "pure core first" pattern the locked bridge used.
EVENT_COVENANT_SPENTadded tosubgraph_manifest(#[allow(dead_code)]until the wiring resolves it). The locked covenant's detector kind resolves the spend handler, so the data source whosepatternsmatched the lock also owns the transition.CovenantSpendenvelope (operation/spentValueSompi/successorCovenantId), serdecamelCaseto match the CLI codegen'sCovenantSpendTS interface — protocol-observable fields only; subgraph quantities stay mapping-derived.spend_mapping_event(spend, prior_state, daa, hash, handler)builds the{ spend, state }payload codegen types for spend handlers, wrapping the prior locked detector state understate.dispatch_spend_hit(...)runs a compiled mapping for a spend, seedingstore_getfrom the committed snapshot, returning DAA-versioned records — mirrorsdispatch_locked_hit.- 3 unit tests (payload shape, null successor on lineage termination, snapshot-seeded dispatch via the WAT handler). node at 49 tests;
cargo build --workspace --all-targetswarning-clean. - What's left to wire spend dispatch end-to-end (the side-effecting glue this unblocks): the block model (
BootstrapBlock/kasgraph_rpc::IngestedBlock) currently carries transaction outputs only, so the node can detect lock-time covenants but not spends. Wiring needs: (1) transaction inputs added to the wire model inkasgraph-rpc; (2) a covenant-UTXO tracker — thekasgraph_covenant_lineage_*store tables +upsert_covenant_lineage_head/insert_covenant_lineage_rowexist but the node's committed loop doesn't populate them yet; (3) per committed block, match inputs against tracked covenant UTXOs, build theCovenantSpendenvelope from the spend tx + lineage head, resolve theCovenantSpenthandler via the descriptor, and calldispatch_spend_hit. Needs the wire change + Postgres to verify.
The loop from detector hit to persisted entity state is now closed end-to-end (everything except a live Postgres round-trip, which this environment can't run). Three slices, in order:
- CLI emits a resolved manifest descriptor (commit
27a5a76).kasgraph buildnow writesbuild/manifest.jsonnext to the wasm:{ name, wasm, dataSources:[{ name, kind, patterns, collection, addresses, handlers:[{event,handler}] }] }. This keeps the TS CLI the singlesubgraph.yamlparser — the node consumes the JSON withserde_json. +1 cli-build test → 173 TS. - Node parses the descriptor + resolves handlers (commit
130efbe,kasgraph-node::subgraph_manifest).BuildDescriptor::load(dir)reads<dir>/build/manifest.json;resolve_handler(detector_kind, event)finds the first data source whosepatternsinclude the kind, then its handler for that event (None→ no mapping handles the hit). 5 unit tests. LoadedMapping+ live-loop wiring (commits429dfee,d52614d,kasgraph-node::mapping_host).LoadedMapping::load(subgraph, dir)compiles the wasm once;dispatch_committed_hits(daa, hash, hits, snapshot)resolves theCovenantLockedhandler per hit, dispatches seedingstore_getfrom the snapshot, and returnsEntityVersionRecords (a hit no data source matches is skipped; a handler that fails is logged + skipped so one bad mapping can't stall the indexer).persist_bootstrap_stateloads an optional mapping fromKASGRAPH_SUBGRAPH_DIR(unset → unchanged behavior); it threads throughapply_and_persist_notification+run_continuous_ingestion. In the committed-writes loop: build the snapshot viaStore::snapshot_entities, dispatch, persist each record viaStore::upsert_entity_version. On a committed reorg:Store::unwind_entity_versionsat/above the lowest unwound DAA. node at 46 tests, workspace warning-clean.- What's left to verify / next: (1) end-to-end against a real Postgres — set
KASGRAPH_DATABASE_URL+KASGRAPH_SUBGRAPH_DIRto a built example and confirmentity_versionsrows land + unwind on reorg (needs a DB;sqlx::testcoverage is the durable form). (2) The example mappings only export lock-time (CovenantLocked) handlers' worth of logic against detector hits; spend events (CovenantSpent) ride the separate KIP-20 lineage path and aren't dispatched yet — wiring spend dispatch is the next functional slice. (3)deploy/status/logs/removeCLI commands + Phase 5 hosted infra.
kasgraph-node::mapping_host(new module, commit9c8aa2e) — the pure, deterministic core of wiring wasm dispatch into the committed ingest loop, landed complete and unit-tested ahead of the side-effecting glue. Three functions:locked_mapping_event(hit, daa, hash, handler)(lock-time detector hit → typedMappingEvent, hit payload becomes event payload),entity_versions(outcome, subgraph, daa)(a dispatch outcome'sEntityOps → DAA-stampedEntityVersionRecords, ready forupsert_entity_version; a reorg unwinds them by the same score), anddispatch_locked_hit(...)(run a compiledMappingRuntimeagainst a hit, seedingstore_getfrom the committedEntitySnapshot, return records + raw outcome; dispatch errors propagate so the caller decides how a bad mapping is handled).- 4 unit tests: payload/block/handler passthrough, op stamping with subgraph+daa, a WAT-driven end-to-end proving the seeded
("Bond","b1")snapshot reaches the guest and its emitted op flows back as a versioned record, and ABI-mismatch propagation for an unknown handler.kasgraph-node→ 38 tests; build warning-clean (the module isallow(dead_code)until the live loop calls it). - Remaining node wiring (the side-effecting glue this unblocks): at the committed-block detector-hit loop in
apply_and_persist_notification(main.rs ~line 822, where each hit becomes aDetectedPatternRecord), for each configured subgraph: (1) load its built wasm — convention isbuild/<name>.wasmfrom the subgraph dir produced bykasgraph build— into aMappingRuntimeonce at startup (not per block); (2) resolve which handler to dispatch from the manifest (lock-time hits → the dataSource'sCovenantLockedhandler; spend events are a separate KIP-20 lineage path); (3) build theEntitySnapshotviaStore::snapshot_entities/latest_entity; (4) calldispatch_locked_hit; (5) persist the returned records viaStore::upsert_entity_version; (6) callStore::unwind_entity_versionson reorg alongside the existing committed-block unwind. Open design decisions before this lands: how subgraph dir / wasm location is configured into the node, manifest parsing on the Rust side (currently only the TS CLI parsessubgraph.yaml), and Locked-vs-Spent event typing. Needs Postgres to verify end-to-end.
kasgraph-storegains the mapping-output substrate. The wasm runtime emitsEntityOps per dispatch; this layer is where they land, versioned by DAA score so a reorg can unwind them alongside committed blocks. Commit7acec60.- New value types
EntityVersionRecord(subgraph + entity_type + entity_id + block_daa_score + payload) andEntitySnapshotRow(entity_type + entity_id + payload). Four asyncStoremethods:upsert_entity_version(idempotent on(entity_type, entity_id, block_daa_score)),latest_entity(highest-DAA row for a key — seedsstore_geton dispatch),snapshot_entities(one row per entity key viaDISTINCT ON ... ORDER BY block_daa_score DESC),unwind_entity_versions(deletes rows at or above a reorg cutoff). - The per-subgraph
entity_versionstable is created byensure_subgraph_schema(PK(entity_type, entity_id, block_daa_score)). Schema name is validated lowercase/digit/underscore, so theformat!-interpolated table-qualified name is injection-safe — matching the existing pattern in this crate. - Tests: 4 pure SQL-builder unit tests (no DB needed) →
kasgraph-storeat 7.cargo test --workspacegreen. - Next slice (unchanged, now unblocked on the store side): wire wasm dispatch into
kasgraph-node's ingest loop. Load the built module per subgraph, dispatch detector events, seedstore_getfromsnapshot_entities/latest_entity, persist emittedEntityOps asEntityVersionRecords viaupsert_entity_version, and callunwind_entity_versionson reorg alongside the existing committed-block unwind. Needs design decisions (where built wasm lives / how it's configured, manifest→handler-name resolution, Locked-vs-Spent event typing) and Postgres to fully verify.
- Runtime entity reads.
kasgraph-mappinggains a third host import:kasgraph.store_get(ePtr,eLen,idPtr,idLen) -> i64. Returns0on a miss; on a hit the host re-enters the guest'skasgraph_alloc, writes the looked-up entitydataJSON into guest memory, and returns(ptr<<32)|len. Newdispatch_with_entities(event, &EntitySnapshot)seeds the read set (HostState.entities); the borrow dance clones the bytes before re-entering alloc. 2 new WAT-driven tests (hit echoes JSON, miss returns zero) → crate at 12 tests. Commiteb58b7a. @kasgraph/as-mappingSDK (new fifth npm workspace package,as-mapping/assembly/index.ts). Typed helpers over the host ABI so mappings never touch raw pointers:decodeEvent(ptr,len): Event(daaScore/blockHash+payload: JSON.Obj|null),log(level,msg),store.get(entity,id): JSON.Obj|null/store.set(entity,id,data), andobjStr/objU64/objBoolfield accessors. JSON viaassemblyscript-json.kasgraph buildresolves it + its transitive AS deps by adding every ancestornode_modulesas anasc --pathroot (asc does not walk up the tree). Commitfea6f57(SDK + build resolution); accessor helpers added this arc.- All six example mappings ported.
examples/{kasbonds,krc20,krc721,network-stats,opensilver-patterns,zk-proofs}/src/mapping.tsrewritten from async TS pseudo-code into compilable AssemblyScript targeting the ABI through the SDK. Each handler isexport function handleX(ptr: i32, len: i32): void; the build glue re-exports them. They implement the lifecycle logic the pseudo-code described — entity create/update, branch onspend.operation/successorCovenantId, supply + counter accumulation viastore.get. KasBonds now keys its persistent Bond lifecycle on the stablecovenantIdcarried in lock/spend payloads; block hash remains only a legacy fallback for old fixtures that omit covenant identity. tests/examples-build.test.tsrunsrunBuildon every example (cwd = the example dir, inside the repo so the hoisted rootnode_modulesis an ancestor) and asserts an ABI-valid wasm: exportsmemory+kasgraph_alloc+ each manifest handler; imports onlykasgraph.{log,store_set,store_get}. 6 cases.- Verification:
npm run typecheckclean; fullnpx vitest run→ 172 TS green (was 166, +6);cargo test -p kasgraph-mapping→ 12 green. - Next slice: wire wasm dispatch into
kasgraph-node's ingest loop — load the built module per subgraph, dispatch detector events, seedstore_getfrom committed entity state, persist the emittedEntityOps. That closes the loop to a live subgraph and unblocksdeploy/status/logs/remove.
cli/src/build.ts(runBuild) compiles a subgraph's AssemblyScript mapping into a.wasmthe Phase 2.6kasgraph-mappingruntime can load.assemblyscriptis now a@kasgraph/clidependency; the command drives it via the programmaticasc.main()API (works from any cwd, no PATH/spawn coupling).- Flow: parse
subgraph.yaml→ map eachhandlers[].handlerto itsmapping.file→ generatebuild/entry.tsthat supplieskasgraph_alloc(bump allocator over the--runtime stubheap) and re-exports each handler under the runtime's lookup name →ascwith--optimize --runtime stub --use abort=→ writebuild/<name>.wasm. --use abort=is load-bearing: AssemblyScript otherwise emits anenv.abortimport the runtime linker doesn't define, which would fail instantiation. Dropping it makes an aborting handler trap instead (classified asHandlerTrapby the runtime).- ABI verification gate (post-compile, via Node
WebAssembly.Module.exports/imports): the wasm must exportmemory+kasgraph_alloc+ every manifest handler, and must import nothing outsidekasgraph.{log,store_set}. A mapping that compiles but violates the contract is rejected. Exit codes: 66 missing manifest/mapping, 65 parse/compile/ABI-violation, 69 missing toolchain. - Tests:
tests/cli-build.test.ts(8) compiles a real AS fixture and asserts the export/import shape, plus missing-manifest / missing-mapping / no-handlers / compile-error / missing-handler-export / stray-host-import paths. Total: 111 cargo + 166 TS = 277 tests green; typecheck clean. - Known gap / next slice: the six
examples/mappings are async TS pseudo-code (Promise,await, closures) — AssemblyScript can't compile them. Porting them needs an AS authoring surface: a JSON decode for the{block,payload}event the host writes at(ptr,len), and an entity-store API that serializesEntityOps throughkasgraph.store_set. That's what closes the loop to a real dispatchable subgraph.
kasgraph codegennow distinguishes spend semantics from lock-time state. Oncovenant_idsources, aCovenantSpenthandler's payload becomes{ spend: CovenantSpend; state: <stateType> }, whileCovenantLockedkeeps the plain detector-state union (orunknownfor unregistered families).- The
CovenantSpendinterface is emitted once per events.ts and carries only protocol-observable, registry-independent fields:operation: string,spentValueSompi: string,successorCovenantId: string | null. These come from the spend tx + KIP-20 lineage tracker (Phase 2.4), so they're honest regardless of whether the locked covenant's detector pattern is registered (e.g. zk-proofs gets{ spend: CovenantSpend; state: unknown }). - Subgraph-specific quantities the example mappings reference (amount, minted/burned deltas, new controller balance) stay derived by the mapping — they are not invented onto the typed payload. The krc20 example pseudo-code now reads
event.payload.spend.operation/event.payload.spend.successorCovenantIdand derives amounts via helpers. - Tests: cli-codegen +1 ("attaches the spend envelope only to CovenantSpent, not CovenantLocked") → 15 cases; examples suite krc20 + zk-proofs assert the
CovenantSpendinterface and wrapped payload. Total: 111 cargo + 158 TS = 269 tests green; typecheck clean. Commit3e32ca5. - Next slice: the
kasgraph buildcommand — compile an AssemblyScript mapping (targeting the Phase 2.6 host/guest ABI) to a.wasmthe runtime can load, which unblocksdeploy/status/logs/removeand real subgraph execution.
kasgraph-mappinggoes from a no-op stub to a working sandboxed runtime. wasmtime is the chosen host engine (Bytecode Alliance flagship), picked for its determinism controls. This closes the long-standing "next big framework decision".- Engine
Configdeterminism lockdown:consume_fuel(true)(bounded execution — a runaway handler tripsTrap::OutOfFuelrather than stalling the indexer),cranelift_nan_canonicalization(true)(bit-identical floats across machines),wasm_threads(false),wasm_relaxed_simd(false). Eachdispatch()runs in a freshStore, so handlers can't smuggle state across blocks. - Host/guest ABI (also drives the future
kasgraph buildAssemblyScript target): guest exportsmemory,kasgraph_alloc(i32)->i32, and onehandler(ptr,len)per manifest handler; guest importskasgraph.log(level,ptr,len)+kasgraph.store_set(ptr,len). The host writes event JSON ({block:{daaScore,hash},payload}) into guest memory viakasgraph_alloc, calls the handler, and collects emittedMappingLogs +EntityOps intoDispatchOutcome. dispatch()classifies failures precisely:OutOfFuel→FuelExhausted{handler}, malformedstore_setJSON→DecodePayload, missing/mistyped exports→AbiMismatch, anything else→HandlerTrap{handler,message}.from_wasm()validates required exports up front and parses both.wasmand.wat.- Tests: 10 unit tests on hand-written WAT fixtures — entity-op capture, leveled-log capture, handler trap, infinite-loop fuel exhaustion, malformed-op decode error, missing-handler/missing-memory/missing-alloc ABI mismatches, cross-run determinism, per-dispatch store isolation. No AssemblyScript toolchain needed at this layer. Total: 111 cargo + 157 TS = 268 tests green;
cargo fmtclean, zero warnings. - Next slice: spend-semantic payload codegen (STATUS item 7), then the
kasgraph buildcommand that compiles an AssemblyScript mapping (targeting the ABI above) to a.wasmthis runtime can load — which in turn unblocksdeploy/status/logs/removeand real subgraph execution.
kasgraph codegennow types handler payloads instead of emittingpayload: unknowneverywhere. Consumes the detector schema landed in the previous commit.- New
cli/src/detector-schema.ts— committed mirror of the Rust registry (kind + field names + byte widths), generated bycli/scripts/gen-detector-schema.mjsfromdump-registryJSON. Kept as a committed artifact so the published CLI has no cargo dependency at codegen time; the gen script reproduces it byte-for-byte and codegen tests pin specific detector shapes to catch drift. codegen.tsevent renderer: for each dataSource, readssource.ids[].patternselectors; every selector that resolves in the schema emits a<Kind>Stateinterface (covenant-state fields, all hexstring, plus adetectorKindliteral discriminator). A handler'spayloadbecomes the union of its data source's detector states. Event interfaces are now deduped by event name (union'd across data sources) instead of one-per-handler, so shared event names can't produce duplicate declarations.- Pattern-less sources (krc721
collection, utxoaddresses) and unregistered selectors (ZK-aware family, not yet in the registry) keeppayload: unknown— verified against the Phase 6 examples: krc20 → union of 5 KCC20 states, opensilver-patterns → union of 12, kasbonds → 2, krc721 / network-stats / zk-proofs →unknown. - Tests: +2 cli-codegen cases (typed union from registered patterns; unknown for unregistered + pattern-less), plus payload assertions added to the examples suite. Total: 101 cargo + 157 TS = 258 tests green. Typecheck clean.
- Known gap (next slice): the typed payload covers lock-time covenant state only. Spend-semantic fields the example mappings reference (
operation,amount,newControllerCovenantId,toPubkey, …) are not registry fields; they need the mapping-runtime ABI to define the spend payload shape. That ABI is part of the Phase 2.6 WASM mapping runtime — the next big framework decision (AssemblyScript→WASM per PLAN; host-engine fork wasmtime vs wasmer), which also gates the remaining Phase-4 CLI commands and real deployment. That decision needs user input before starting.
kasgraph-detectorsnow exposes the live registry as a machine-readable schema, the bridge that unblocks per-detector payload codegen on the TS side.- New
registry_schema() -> RegistrySchemawalksregistry::all()and emits{ version, detectors: [{ kind, fields: [{ name, byte_len }] }] }. Field names + byte widths come straight from each fingerprint'smasked_windows. (Reminder: at runtimepayload_to_jsonhex-encodes every field, so every field maps to astringin TS regardless ofbyte_len.) - New
src/bin/dump-registry.rs:cargo run -p kasgraph-detectors --bin dump-registry > detector-schema.json. Pretty-prints the schema to stdout. Kept as an on-demand exporter, not a committed artifact, so it can't drift. - 3 new unit tests: schema covers every registered detector (count + non-empty + version + non-empty field names + positive widths);
OpenSilverMultisigfield shape pinned (signer_pubkeys/96,threshold/1); JSON round-trip. Detectors crate: 17 → 20 tests. - Total: 101 cargo + 155 TS = 256 tests green.
cargo build --workspace --all-targetsclean (the new bin compiles). - Next (the last self-contained slice before WASM): wire
@kasgraph/clicodegen to consume thedump-registryJSON and generate a typed payload interface perpattern:selector in a manifest, replacingpayload: unknown. Every field →string(hex). Then the Phase 6 example mappings can drop their pseudo-code and decode typed payloads. After that, the big fork is the Phase 2.6 WASM mapping runtime — needs a user decision (wasmtime vs. wasmer vs. embedded JS), which gates the remaining Phase-4 CLI commands (build/deploy/status/logs/remove) and real subgraph deployment.
examples/now ships all six reference subgraphs. Phase 6 is complete: 6.1 kasbonds ✓, 6.2 opensilver-patterns ✓, 6.3 krc20 ✓, 6.4 krc721 ✓, 6.5 network-stats ✓, 6.6 zk-proofs ✓.examples/krc721/(Phase 6.4) — native covenant-era NFTs viakind: krc721with acollection: "*"firehose. Entities:KRC721Collection/KRC721Token/KRC721Holder/KRC721Mint/KRC721Transfer. NFT-semantic handlers (handleCollectionDeployed/handleNftMinted/handleNftTransferred/handleNftBurned) abstract the underlying collection + per-NFT covenant lineage perdocs/references/KRC20_KRC721_REFERENCE.md. README flags the native spec as still firming up (krc721.stream maintainers canonical).examples/network-stats/(Phase 6.5) — chain-wide aggregates viakind: utxowithaddresses: ["*"](the firehose). Entities:BlockStat(per-block production + tx/fee totals),DailyStat(per-UTC-day rollup),AddressActivity(per-address lifetime counters). HandlershandleBlockAdded(block production from the block stream) +handleUtxoChanged(tx volume / fees / address activity from the firehose). Chosenutxobecause there is no block-level data-source kind; block-production counters ride the block stream the indexer already consumes.examples/zk-proofs/(Phase 6.6, KIP-16) — Groth16 proofs in covenant spends viakind: covenant_id. Entities:ZkVerifyingKey(registered at covenant lock) /ZkProof/ZkWitness(blob in object storage, row holds URI + sha256 per Phase 2.7 storage split) /ZkVerification(verify outcome recorded at index time). HandlershandleVerifyingKeyRegistered(CovenantLocked) +handleProvenSpend(CovenantSpent). Pattern selectorsZkVerifierGroth16/ZkRollupCommit/ZkPrivateTransferare committed names; their detector-registry entries land when OpenSilver exports the ZK-aware pattern family (same pending posture as the placeholder fingerprint bytes).tests/examples.test.tsit.eachextended from 3 → 6 examples; each new subgraph gets per-example entity + event-interface assertions. The generic registry/manifest/kind/handler-match checks already covered the new dirs automatically (they iterateexamples/).- Total: 98 cargo + 155 TS = 253 tests green. Typecheck clean across all four TS packages. No Rust touched.
- Next: Phase 6 is done; the remaining Phase-4 CLI commands (
build/deploy/status/logs/remove) and any real deployment of these subgraphs both gate on the Phase 2.6 WASM mapping runtime — the next big framework decision — and Phase 5 hosted infra.
examples/was empty; now ships three reference subgraphs that double as integration smoke tests for the CLI pipeline.examples/kasbonds/(Phase 6.1) — first dogfooding customer; Bond / Holding / Coupon entities;OpenSilverVault+OpenSilverEscrowMilestonepattern source;handleBondIssued+handleBondTransitionhandlers with documented pseudo-code for the full impl.examples/opensilver-patterns/(Phase 6.2) — every detector kind incrates/kasgraph-detectors/src/registry.rsas apattern:selector; genericPatternInstance+ specialised projections for Vault / Multisig / Escrow.examples/krc20/(Phase 6.3, native KCC20) — replaces Kasplex for the post-Toccata era; KCC20Asset + KCC20Controller (with controllerKind discriminator) + KCC20Holder + KCC20Transfer + KCC20Mint entities; one handler each for asset/controller deployment and asset transitions covering transfer/mint/burn/rotate-controller.- Every example follows the same layout:
subgraph.yaml+schema.graphql+src/mapping.ts+README.md. tests/examples.test.tsadds 7 vitest cases that double as CLI integration tests:- Registry shape: every example dir has the four expected files.
- Manifest shape: every subgraph.yaml parses + carries the right top-level fields + uses only documented dataSource kinds.
it.each(['kasbonds', 'opensilver-patterns', 'krc20'])runsrunCodegenagainst the example dir and asserts the produced entities + events have the expected interfaces.- Cross-cutting check: every
handler.eventname in every manifest gets a matching${event}Eventinterface in the generatedevents.ts.
.gitignoreaddsexamples/**/src/generated/so the regenerated files don't pollute commits.- Total: 98 cargo + 152 TS = 250 tests green. Typecheck clean across all four TS packages.
- Phase 6 status: 3 of 6 reference subgraphs landed (6.1 KasBonds ✓, 6.2 OpenSilver Patterns ✓, 6.3 KRC-20 ✓). 6.4 KRC-721, 6.5 Network Stats, 6.6 ZK Proofs queue up the same template — each is a clean ~60-line slice once their data-source schemas firm up.
kasgraph-apioperator binary now autoconfigures GraphQL subscriptions from env. A container with justDATABASE_URLset serves Query, healthz, AND liveSubscription.detectedPatternsover SSE — no extra wiring.RunServerOptionsgainssubscriptionsEnabled+listenDatabaseUrl.readOptionsFromEnv:KASGRAPH_SUBSCRIPTIONS_ENABLED(defaulttrue; flip tofalseto disable cleanly)LISTEN_DATABASE_URL(default falls back toDATABASE_URL; override for a read replica or a different role with NOTIFY privileges)
runKasGraphServerbuilds aPgListenSourcewith aconnectfactory returning a freshpg.Clienteach lazy-reconnect; passes it tocreateKasGraphServervia conditional spread (honorsexactOptionalPropertyTypes:true);shutdown()callssource.close()before ending the pool.PgListenSource.onErrorrouted through the same structured JSON-line logger as the rest of the binary, prefixed withPgListenSource:for grep-ability.tests/main.test.tsadds 4 new vitest cases: defaults assertsubscriptionsEnabled=true+listenDatabaseUrl=DATABASE_URL;KASGRAPH_SUBSCRIPTIONS_ENABLED=falsedisables; every standard truthy value (1/true/TRUE/True/yes) enables;LISTEN_DATABASE_URLfalls back + overrides.- Total: 98 cargo + 145 TS = 243 tests green. Typecheck clean across all four TS packages.
- Phase 3.4 status: in-process source ✓, Postgres LISTEN/NOTIFY source ✓, schema + Yoga wiring ✓, operator binary auto-wiring ✓. The gateway can now stream every detector hit the indexer writes — no app-side
pg_notifycall, no extra setup beyondDATABASE_URL. Live SSE: hit aSubscriptionagainstGET /graphql?query=subscription{...}withAccept: text/event-streamand events stream as Postgres notifications arrive.
- Closes the indexer → gateway live loop without in-process StreamHub coupling.
- New migration
20260528120000_detector_hits_notify.sqladdskasgraph_notify_detected_pattern()PL/pgSQL function +AFTER INSERTtrigger onkasgraph_detected_pattern. Payload isjson_build_object(...)matching the GraphQLDetectedPatternshape with BIGINT serialized as text to avoid JS Number precision loss. Any writer to the table participates — Rust indexer today, manual SQL, future writers — no app-sidepg_notifycall needed. Migrator test bumped to 4 migrations. api/src/pg-listen.ts:PgListenSource implements SubscriptionSourcebuilt around a minimalPgListenClientinterface (connect/query/on/removeAllListeners/end) so tests mock the client and production wiresnew pg.Client(connectionString). Lazy-connect on first subscribe (LISTEN once, fan out via internalInMemorySubscriptionSource), UNLISTEN +end()when the last subscriber drops, lazy-reconnect when a new subscriber arrives afterclose().- Payload normaliser: requires
subgraph/blockHash/blockDaaScore/txHash/outputIndex/detectorKind(rejects malformed payloads silently rather than emitting half-shaped events). OptionalcovenantId/payloadomitted from the response when null. BIGINT delivered as either string or number is coerced to string. tests/pg-listen.test.tsadds 12 vitest cases using aFakeListenClientrecording every call + holding the registered notification listener: lazy LISTEN on first subscribe; multiple subscribers share one LISTEN; UNLISTEN + end on last drop; payload routing in order; optional-field omission; required-field-missing dropped; empty-payload notification dropped; wrong-channel notification dropped; malformed JSON dropped viaonError; subscriber stays usable after the malformed payload;close()UNLISTENs even with active subscribers; lazy-reconnect after close.- Total: 98 cargo + 141 TS = 239 tests green. Typecheck clean across all four TS packages.
- Phase 3.4 status: in-process source ✓, Postgres LISTEN/NOTIFY source ✓, schema + Yoga wiring ✓. The gateway can now stream every detector hit the indexer writes — no in-process coupling, no polling, no NOTIFY call on the Rust side. Wiring
PgListenSourceinto the operator binary (sokasgraph-apiautoconfigures subscriptions when aLISTEN_DATABASE_URLis set) is the next jump for that phase.
@kasgraph/apinow ships aSubscriptiontype + a pluggableSubscriptionSourcecontract. The gateway becomes the first end-to-end consumer of the Phase 3.4 "live event" model.KASGRAPH_BASE_SCHEMA_SDLgainstype Subscription { detectedPatterns(subgraph, kind, covenantId): DetectedPattern! }with all three args optional + AND-combined.api/src/subscriptions.ts:SubscriptionSourceinterface (subscribeDetectedPatterns(filter) → AsyncIterable<DetectedPattern>) +InMemorySubscriptionSource(process-local pub/sub on top of a custom AsyncIterable; back-pressure via an unbounded queue per subscriber; cleanly drops the subscriber on iteratorreturn()).matches(event, filter)exported so a future PgListenSource reuses the same filter semantics.createKasGraphServerswitches schema construction frombuildSchema+ rootValue (which couldn't express subscriptions) to graphql-yoga'screateSchemawith field resolvers forQuery.*+ asubscribe/resolveresolver forSubscription.detectedPatterns.executeGraphQLQuerykeeps usinggetKasGraphSchema()+ rootValue for the in-process Query-only path.- Subscription field rejects every subscribe with a clear "subscriptions are not configured" error when
subscriptionSourceis omitted — clients see a cause instead of a silent hang. - Yoga ships SSE for subscriptions out of the box; WebSocket transport can layer on with
graphql-wsin a later slice. tests/subscriptions.test.tsadds 11 vitest cases: filter semantics across every key + AND combinations + "hit without covenantId vs filter with covenantId"; InMemory ordering, await-before-publish, iterator-return drops subscriber, pending-iterator-resolves-done-on-return, fan-out delivery; schema introspection round-trip confirms Subscription appears; subscribing without a source surfaces the expected error.- Total: 98 cargo + 129 TS = 227 tests green. Typecheck clean across all four TS packages.
- Phase 3.4 status: in-process subscription path complete. Next slice: a
PgListenSourcethat LISTENs on akasgraph_detected_patternchannel (paired with a tinyNOTIFYtrigger or app-sidepg_notifycall fromkasgraph-node) so the gateway streams hits the indexer writes — closing the loop without an in-process StreamHub coupling.
kasgraph codegenis live. Reads./subgraph.yaml+./schema.graphqlfrom the subgraph directory and writessrc/generated/{entities,events}.ts.- Entity rendering walks every non-root
ObjectTypeDefinitionin the SDL; maps GraphQL scalars to TypeScript (String/ID→string,Int/Float→number,Boolean→boolean,BigInt→bigint,Bytes→ hexstring,JSON→unknown); preserves non-null vs optional (nullable becomes?: T); renders lists asArray<T>with inner nullability collapsing toT | null; object-type references stay as the referenced interface name (foreign-key style). - Event rendering walks every handler across every dataSource in the manifest and emits one interface per handler with a fixed envelope:
eventliteral,block { hash, daaScore (bigint), blueScore (bigint) },tx { hash, index }, andpayload: unknown(per-detector payload codegen lands in a later slice). - Exit codes follow EX_ conventions: 66 (
EX_NOINPUT) whensubgraph.yamlorschema.graphqlis missing; 65 (EX_DATAERR) on parse failure; 0 on success with a summary line. - The
init→codegenflow now closes cleanly: the init template'smapping.tsimports./generated/events.jsand codegen produces exactly that file withCovenantLockedEvent+CovenantSpentEventmatching the scaffolded handler names. tests/cli-codegen.test.tsadds 12 vitest cases against tmp-dir fixtures: missing-input errors, malformed SDL error, every scalar mapping, list inner-nullability, object-type references, Query/Mutation skipping, multi-dataSource event rendering, no-handlers empty body, full init+codegen end-to-end,runCommand("codegen")path, regeneration over stale generated files.cli/package.jsongainsgraphql ^16.10.0+yaml ^2.6.0. Typecheck clean across all four TS packages.- Total: 98 cargo + 118 TS = 216 tests green.
- Phase 4 CLI status:
init✓,codegen✓,mcp-config✓.build/deploy/status/logs/removestill wait on the Phase 2.6 WASM mapping runtime (the next big framework decision).
@kasgraph/cligoes from a dispatch shim to a real CLI. NewrunCommand(argv, io)returns a numeric exit code and routes to per-command bodies.kasgraph init <name>scaffolds a working subgraph dir:subgraph.yaml(matching theSubgraphManifestshape from@kasgraph/sdk),schema.graphql(KasBonds-style placeholder),src/mapping.ts(handler stubs),package.json,.gitignore,README.md. Validates the name against^[a-z0-9][a-z0-9_-]{0,63}$. Refuses to clobber an existing directory.kasgraph mcp-configprints a Claude Desktop / Cursor / OpenClawmcpServersJSON snippet wiringkasgraph-mcpover stdio. Flags:--database-url,--command,--server-name.- Commands recognized but not yet implemented (
codegen,build,deploy,status,logs,remove) surface a clear "not implemented yet (Phase 4 WASM pipeline pending)" message and exit 64. cli/src/cli.tsis now a thin shim wiringprocess.argv/env/stdout/stderrintorunCommand; everything else lives in modules that tests exercise without spawning a child process.tests/cli.test.tsadds 18 vitest cases against aCapturedIo+ per-test tmpdir: dispatch shape, init validation (missing name / bad regex / clobber refusal), scaffolded-file assertions (subgraph.yaml + package.json shape, placeholder schema entity), mcp-config flag parsing, JSON shape, and an end-to-end runCommand path for both subcommands.- Total: 98 cargo + 106 TS = 204 tests green. Typecheck clean across all four TS packages.
- Phase 4 (developer CLI) now has its two no-WASM-required commands live.
codegen+build+deploywait on the WASM mapping runtime (Phase 2.6 — the next big framework decision).
@kasgraph/mcpnow ships a real MCP server + operator binary (bin: { kasgraph-mcp: dist/main.js }) over the canonical@modelcontextprotocol/sdkServer withStdioServerTransport.mcp/src/server.ts:createKasGraphMcpServer(handlers)registersListToolsRequestSchema(returnsmcpToolListing()in canonical order) andCallToolRequestSchema(routes throughdispatchMcpTool→ MCP text-content blocks).runMcpStdioServer(handlers)connects stdio.callToolToContent(name, args, handlers)factored out as the pure helper that wraps results (or structured errors with codesunknown_tool/invalid_input/handler_error) in the MCP{content: [{type:'text', text: JSON}]}shape. Pure-function design means vitest exercises it without a transport.mcp/src/main.ts: env-driven entry mirroringkasgraph-api. ReadsDATABASE_URL(orKASGRAPH_DATABASE_URL), buildspg.Pool+PgMcpHandlers, connects to stdio, registers SIGINT/SIGTERM handlers that close transport + server + pool. Logs to stderr so stdout stays clean for the MCP protocol frames.tests/mcp-main.test.tsadds 10 vitest cases: server constructs without throwing; success/route paths produce one text content block; unknown tool / missing required field / handler-thrown-error each map toisError:truewith the right structured code; env reader honors precedence + both env-var names.@modelcontextprotocol/sdk ^1.0.0andpg ^8.13.0added tomcp/package.jsondeps. Typecheck clean across all four TS packages.- Total: 98 cargo + 90 TS = 188 tests green.
- Phase 3.2 (MCP, CRITICAL per PLAN.md) is now operationally complete: typed contract → in-memory handlers → Postgres handlers → MCP server scaffolding → env-driven stdio binary. A container running
kasgraph-mcpwith justDATABASE_URLset is reachable from any MCP client (Claude Desktop, IDE plugins, custom LLM agents).
@kasgraph/apinow ships a real operator binary atdist/main.js(exposed viabin: { kasgraph-api: ... }). ReadsDATABASE_URL/KASGRAPH_DATABASE_URL(required),HOST(default0.0.0.0),PORT(default4000),GRAPHQL_ENDPOINT(default/graphql),GRAPHIQL(defaulttrue).- Splits cleanly so the routing + healthz logic is unit-testable without binding sockets:
healthzResponse(pool)→{ status, body, contentType }(200 onSELECT 1success; 503 with error message on throw)createKasGraphHttpHandler(yoga, healthCheck)→ Node(req, res) => void. RoutesGET/HEAD /healthzto the health check, 405s anything else on that path, forwards everything else to Yoga.runKasGraphServer(options)→ starts http server, returns{ address, shutdown() }main()→readOptionsFromEnv→runKasGraphServer→ SIGINT/SIGTERM handlers that cleanly close the http server and end the pg pool
- Structured JSON-line logging to stdout (info/warn) and stderr (error) — operators can pipe through
jqor redirect without parsing. tests/main.test.tsadds 13 vitest cases against actual Node http servers bound to port 0 + aStubPooland a recording sentinel Yoga handler. Covers: healthz 200/503 shape, GET/HEAD/POST routing,/healthz?qsmatching, 405 on POST, non-healthz forwarding to Yoga, env defaults + overrides for every read knob,KASGRAPH_DATABASE_URLfallback, non-numeric PORT degradation.- Total: 98 cargo + 80 TS = 178 tests green. Typecheck clean across all four TS packages.
- Phase 3.1 (GraphQL gateway) is now operationally complete: typed contract → in-memory resolvers → Postgres resolvers → HTTP transport → env-driven binary with healthz and graceful shutdown. Hosted-service infra (Phase 5) can wrap this binary directly in a container.
@kasgraph/apinow shipscreateKasGraphServer({ pool, resolvers?, graphqlEndpoint?, graphiql? })returning a Yoga handler (Fetch-API(Request) => Responsefunction — slots into Nodehttp, Workers, Deno, oryoga.fetchfor tests).- Schema construction stays in
getKasGraphSchema()(rootValue-based); Yoga injectsrootValueat execute time via a tinyonExecuteplugin. The same schema definition + resolvers feed bothexecuteGraphQLQuery(in-process) and the HTTP handler, so they cannot drift. - Vitest config gained a
graphql→node_modules/graphql/index.jsalias plusdedupe: ['graphql', 'graphql-yoga']. Without this, vitest holds both CJS and ESM copies ofgraphqlin the same process and Yoga errors with "Cannot use GraphQLSchema from another module or realm." tests/server.test.tsadds 7 vitest cases usingyoga.fetchdirectly (no socket binding): GraphiQL HTML served on GET, POST query execution against in-memory resolvers, end-to-end POST throughPgGatewayResolversagainst the sameMockPoolpattern aspg-handlers/pg-resolvers, GraphQL-validation errors surface with the right body,__schemaintrospection round-trip,graphiql:falsedisables the UI, andgraphqlEndpointoverride routes the handler to a custom path.- Tests honor the GraphQL-over-HTTP spec:
Accept: application/graphql-response+json, application/jsonon POSTs; validation errors accepted as either 200 or 400 (Yoga returns 400 per spec when the spec-compliant Accept is sent). graphql-yoga ^5.10.0added toapi/package.json.- Total: 98 cargo + 67 TS = 165 tests green. Typecheck clean across all four TS packages.
- Phase 3.1 (GraphQL gateway) is now end-to-end reachable: typed contract → in-memory resolvers → Postgres resolvers → HTTP transport. Next slice for that phase is the operator entry binary (read
DATABASE_URL/PORTfrom env and start a Nodehttpserver wrapping the Yoga handler).
@kasgraph/mcpnow shipsPgMcpHandlersimplementing everyMcpHandlersmethod against the Phase 2.4 + 2.5 schema, mirroringPgGatewayResolversfrom@kasgraph/api.- Fully backed by Postgres:
list_subgraphs(GROUP BY with optionalLOWER(subgraph) LIKEfilter),search_by_pattern(kind filter + bounded limit + ordered),get_covenant_lineage(head + ordered entries; returns empty lineage when head missing instead of throwing). get_schemareturns the canonicalKASGRAPH_BASE_SCHEMA_SDLfor any subgraph id — per-subgraph SDL wires in when the codegen pipeline lands.execute_querydelegates toexecuteGraphQLQuery + PgGatewayResolversagainst the same pool so an MCP client and a GraphQL client see identical results for the same query.- Three unbacked tools —
get_address_activity,find_subgraphs_for_address,query_natural_language— throwMcpHandlerNotImplementedErrorwith a clear reason.NOT_IMPLEMENTED_TOOLSexported fortools/listconsumers that want to surface a "coming soon" hint. @kasgraph/apiadded as a dep + tsconfig reference of@kasgraph/mcpso the gateway pieces (PgPoolLike,executeGraphQLQuery,PgGatewayResolvers,KASGRAPH_BASE_SCHEMA_SDL) are usable.- 14 new vitest cases in
tests/pg-handlers.test.tspin: SQL shape per handler, parameter binding, keyword-LIKE lowercasing, limit clamping, head-missing branch, two-query lineage sequence, end-to-end GraphQL round-trip through the same mock pool, GraphQL parse errors flow through cleanly, each not-implemented tool throws the right error. - Total: 98 cargo + 60 TS = 158 tests green. Typecheck clean across all four TS packages.
- Phase 3.2 now has a real Postgres-backed production handler. The 5 fully-backed tools are production-ready; the 3 unbacked ones surface a clear error. Next slice for Phase 3.2: stdio/SSE transport via
@modelcontextprotocol/sdkso the handlers are reachable from LLM clients.
@kasgraph/apinow shipsPgGatewayResolversimplementing everyGatewayResolversmethod against the Phase 2.4 + 2.5 schema:committedBlock,committedBlocks,poiCheckpoints(with optionalfromDaa/toDaabounds),detectedPatterns(with optionalkindfilter),covenantLineage(head + ordered entries).- Constructed against a minimal
PgPoolLikeinterface — production code passes a realpg.Pool; tests use a recording mock that captures(sql, values)tuples and replays cannedrows[]. - Defensive serializers handle the cross-shape pg type-parser delivery:
bigIntStringfor BIGINT (string OR number),isoStringfor TIMESTAMPTZ (Date OR string),hexFromBytesfor BYTEA (Buffer / Uint8Array / string). boundedFirstclampsfirstto[1, 1000]with default 50; nullcovenant_id/payloadcolumns get omitted from the response (honorsexactOptionalPropertyTypes: true).- 12 new vitest cases in
tests/pg-resolvers.test.tspin: SQL shape per resolver, parameter binding order, optional-clause inclusion,firstclamping/defaulting, hex serialization across buffer/string/Uint8Array, lineage two-query sequence with empty-bytes omission. pg ^8.13.0+@types/pg ^8.11.10added toapi/package.json. Typecheck clean across all four TS packages.- Total: 98 cargo + 46 TS = 144 tests green.
- Phase 3.1 now has a real Postgres-backed production resolver. Next slice for that phase: a Yoga HTTP server wrapping
executeGraphQLQuery+PgGatewayResolversso the gateway responds to real GraphQL requests end-to-end.
@kasgraph/apinow ships the canonical KasGraph base schema (CommittedBlock,PoiCheckpoint,DetectedPattern,CovenantLineage,CovenantLineageEntryplusBigInt+JSONscalars), an executable schema built lazily viabuildSchema, aGatewayResolversinterface, andexecuteGraphQLQuery(request, resolvers)that uses the referencegraphqlengine for parse + validate + execute.BigIntscalar serializes DAA scores as decimal strings (no JS Number precision loss past 2^53).JSONscalar passes payloads through as-is.- Framework choice (Apollo / Yoga / Mercurius per PLAN.md Phase 3.1) is intentionally NOT baked in. This module talks to
graphql/executedirectly; any framework can wrap it as an HTTP transport. Yoga is still the recommended default once Phase 3.4 WebSocket subscriptions land. tests/api.test.tsadds 13 vitest cases against an in-memoryInMemoryResolversimpl with seeded subgraphs/lineage/hits: schema-surface assertions, introspection round-trip, parse/validation error shapes, every query routes correctly with optional-arg omission honored, JSON-scalar payload pass-through, BigInt-as-string serialization.graphql ^16.10.0added toapi/package.jsondependencies.tsc --noEmitclean across all four TS packages.- Total: 98 cargo + 34 TS = 132 tests green.
- Phase 3.1 (GraphQL gateway) goes from a 23-line config interface to a typed schema + resolver + dispatcher contract. Next slice: Postgres-backed
GatewayResolversimpl talking to the Phase 2.4 schema, then a Yoga HTTP server wrapping it.
@kasgraph/mcpwas a name-only enumeration; now declares per-toolMcpTool { name, description, inputSchema }for all 8 tools withadditionalProperties:falseJSONSchema inputs.- Per-tool TypeScript Input/Output types capture the wire shapes (e.g.
SubgraphSummary,CovenantLineageEntry,AddressActivityEntry) so production handlers and test handlers share one contract. McpHandlersinterface declares one method per tool.dispatchMcpTool(name, args, handlers)routes to the right method, validates required input keys, and throwsMcpDispatchError { code: 'unknown_tool' | 'invalid_input' }for malformed calls. Conditional spread keepsexactOptionalPropertyTypes: truehappy.tests/mcp.test.tsadds an in-memoryMcpHandlersimpl with seeded subgraphs/addresses and 16 vitest cases covering registry shape, unknown-tool rejection, missing-field rejection, every tool routing correctly, optional-field omission, and the calls-recorded invariant.- Total: 98 cargo tests + 21 TS tests = 119 green; typecheck clean across all four TS packages.
- Phase 3.2 (MCP, CRITICAL per PLAN.md) goes from "tool names enumerated" to "typed dispatch contract + reference test handler". Next slice for that phase: a production handler backed by
pgthat queries the Phase 2.4 schema, plus a stdio/SSE transport.
continuous_wrpc_smokenow dispatcheskasgraph_detectors::detect_in_outputagainst every output of every receivedBlockAdded/VirtualChainChangedblock — same dispatch the indexer'sblock_from_rpcperforms in production.- Per-kind tally rolled into both the stdout summary line and the
KASGRAPH_WRPC_SUMMARY_JSONartifact (detectorHitsTotal,detectorHitsPerKind { kind: count, ... }). - Per-hit NDJSON event when
KASGRAPH_WRPC_EVENT_NDJSONis set:{kind: "detector_hit", block_hash, block_daa_score, tx_hash, output_index, detector_kind, covenant_id, payload, ts_ms}. kasgraph_detectorsadded as a[dev-dependencies]entry onkasgraph-rpc(kept the production crate dep-free of detectors so the layering stays clean).- The point of this slice: validate against real mainnet outputs that the placeholder
0xFE-prefixed discriminators don't false-positive. A clean soak that reportsdetector_hits_total=0proves the placeholder bytes are safe to keep until real OpenSilver compiled bytes ship. - 98 tests still green; build clean, zero warnings.
kasgraph-nodecreates a singlekasgraph_stream::StreamHubat startup (capacity fromKASGRAPH_STREAM_CAPACITY, default 1024).apply_and_persist_notificationtakesOption<&StreamHub>; after every successful detector-hit DB insert, the same hit is built into aStreamEventand published viapublish_hit_to_stream.- Event payload nests the original
detector_payloadplustx_hash,output_index, and (when present)covenant_idso the existingStreamFilter::CovenantId(...)matcher works on real published events without re-deriving the id from kind-specific payload schemas. - Hub is threaded through to both the bootstrap and continuous paths (continuous-mode recovery re-applies also publish).
- Three new node-side tests pin: All-filter subscriber receives the published event with all expected fields;
Nonehub is a clean no-op;StreamFilter::CovenantIdcorrectly filters published events. - Phase 3.3 (KasStream streaming primitive) now has both a hub implementation and a real producer wired into the node. The gRPC server layer on top is the next jump for that phase.
- 98 tests total (was 95). Build clean, zero warnings.
- New migration
20260526160000_detector_hits.sqladdskasgraph_detected_pattern (subgraph, block_hash, block_daa_score, tx_hash, output_index, detector_kind, covenant_id, payload, detected_at). Three indexes: subgraph+DAA-desc, subgraph+kind+DAA-desc, and a partial covenant-id index. Store::insert_detected_patternusesON CONFLICT DO UPDATEso re-applying a block (e.g. mid-recovery) is idempotent.Store::unwind_committed_blocks_for_subgraphnow also deletes matching detector rows in the same transaction — the same chain bytes always produce the same detector ledger.BootstrapBlockgainsdetector_hits: Vec<DetectedPattern>computed once inblock_from_rpc; production code reads it directly, tests still use the#[cfg(test)]run_detectors_on_blockhelper.canonical_bytes_for_blocknow incorporates sorted detector hits. Each row is rendered asdet:tx_hash:output_index:kind:covenant_id:canonical_payload_json. Sort key is(tx_hash, output_index, kind); payload JSON keys are sorted viacanonicalize_jsonso the bytes are invariant under emission order and under serde's source-key order.- POI now reflects real on-chain state, not just block metadata — the verifiability goal from Phase 2.8 is unblocked.
- Three new node tests pin: canonical bytes change when hits differ; canonical bytes stable under hit reordering; canonical bytes stable under payload key reordering. Store migrator test updated to expect 3 migrations.
DetectorKindderive gainedPartialEq(already hadEqvia the discriminant-only Hash derive);BootstrapBlock/IngestionState/IngestionTransitionshedEqsinceDetectedPattern.payloadis aserde_json::Value.- 95 tests total (was 92). Build clean, zero warnings.
IngestedBlockgainsoutputs: Vec<IngestedTransactionOutput>. Each entry carriestx_hash,output_index, hex-decodedscript_public_key, andvalue(sompi). Serde-defaultkeeps backwards compat with header-only notifications.parse_block_valuenow walkstransactions[].outputs[]from the live wRPC payload, decodingscriptPublicKey.scriptPublicKeyhex strings. Three new tests cover the happy path, the no-transactionscase, and the skip-malformed-entries case while preservingoutput_indexalignment.BootstrapBlockcarries the outputs through to the persist path. The continuous-mode commit loop now callskasgraph_detectors::detect_in_outputover every output of each committed block viarun_detectors_on_blockand logs a per-kind summary (Vault:3,KCC20Asset:1).- Phase 2.5 finally has a real consumer: the detector registry is now exercised on live wRPC traffic, not just in unit tests. Once OpenSilver ships real compiled-script bytes the placeholder discriminators get replaced and live mainnet patterns will surface in node logs immediately.
- Three new node-side tests for
run_detectors_on_block(empty-outputs case, registry-match case, mixed match/non-match) plus a focusedsummarize_detector_hitstest. - 92 tests total (was 86). Build clean, zero warnings.
- New
continuous_subscription_interleaves_events_and_notifications_around_reconnect_gaptest asserts the notification stream AND the driver-event stream agree on ordering around a reconnect-with-gap. - Notification stream:
BlockAdded(10),BlockAdded(11), syntheticRecoveryRequired(12, 14, ...),BlockAdded(15). - Event stream:
Connected(0),ReconnectScheduled(1, ...),Connected(1),GapDetected(1, 12, 14). - Same
reconnect_countthreaded through both streams; same DAA range in synthetic recovery andGapDetectedevent. This is the exact shape the soak runner now persists in NDJSON, so the trace artifact is now grounded by an in-process integration assertion. - 86 tests total. Build clean, zero warnings.
continuous_wrpc_smoke.rsnow acceptsKASGRAPH_WRPC_EVENT_NDJSON=<path>. When set, every driver event (Connected,ReconnectScheduled,GapDetected,Stopped) and every notification (BlockAdded,VirtualChainChanged,RecoveryRequired) is appended as a JSON object with ats_msunix-millisecond timestamp.- Output uses
BufWriter<File>and is flushed + closed cleanly on shutdown. Path parent dirs are created on the fly. - Designed as a replayable trace artifact for diffing soak runs and feeding into the next jump: a targeted integration test that exercises the rpc driver against a mock ws server simulating reconnect + stale-replay + overlap and asserts on the resulting NDJSON.
- 85 tests still green; build has zero warnings.
- Ran:
KASGRAPH_WRPC_DURATION_SECONDS=900 KASGRAPH_WRPC_MAX_MESSAGES=0 KASGRAPH_WRPC_SUMMARY_JSON=/tmp/kasgraph-wrpc-soak-900s.json cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- Observed real soak result against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 5597 notifications in 900 seconds
blocks=2800virtual_chain_changed=2797recovery_required=0highest_daa_seen=444267105reconnects=0connections=1- JSON artifact written successfully to
/tmp/kasgraph-wrpc-soak-900s.json
- This is the first longer live baseline showing the current driver stayed connected cleanly for 15 minutes with no synthetic recovery requests.
- Ran:
KASGRAPH_WRPC_DURATION_SECONDS=300 KASGRAPH_WRPC_MAX_MESSAGES=0 KASGRAPH_WRPC_SUMMARY_JSON=/tmp/kasgraph-wrpc-soak-300s.json cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- Observed real soak result against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 2177 notifications in 301 seconds
blocks=1087virtual_chain_changed=1090recovery_required=0highest_daa_seen=444258625reconnects=0connections=1- JSON artifact written successfully to
/tmp/kasgraph-wrpc-soak-300s.json
- This is the first medium-duration baseline showing the current driver stayed connected cleanly for five minutes with no synthetic recovery requests.
- Ran:
KASGRAPH_WRPC_DURATION_SECONDS=60 KASGRAPH_WRPC_MAX_MESSAGES=0 KASGRAPH_WRPC_SUMMARY_JSON=/tmp/kasgraph-wrpc-soak-60s.json cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- Observed real soak result against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 465 notifications in 60 seconds
blocks=233virtual_chain_changed=232recovery_required=0highest_daa_seen=444252802reconnects=0connections=1- JSON artifact written successfully to
/tmp/kasgraph-wrpc-soak-60s.json
- This is the first longer-duration baseline showing the current driver stayed connected cleanly for a full minute with no synthetic recovery requests.
crates/kasgraph-rpc/examples/continuous_wrpc_smoke.rsnow supportsKASGRAPH_WRPC_SUMMARY_JSON=<path>and writes a structured JSON artifact containing:- counts by notification type
highestDaaSeen- reconnect / connection counts
- stop reason
- observed gap ranges
- advertised capability bits
- Verified live with:
KASGRAPH_WRPC_DURATION_SECONDS=10 KASGRAPH_WRPC_MAX_MESSAGES=0 KASGRAPH_WRPC_SUMMARY_JSON=/tmp/kasgraph-wrpc-soak-summary.json cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- Observed real soak result against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 106 notifications in 10 seconds
highest_daa_seen=444251243reconnects=0connections=1- JSON artifact written successfully to
/tmp/kasgraph-wrpc-soak-summary.json
- Verification after the change:
cargo fmt && cargo test -p kasgraph-rpc- full
cargo test
- This gives the next session a reusable artifact format for comparing 1m / 5m / 15m live soaks.
kasgraph-rpcnow exposesSubscriptionDriverEventplusspawn_continuous_subscription_with_events(...)so long-lived smoke/integration flows can observe:- connect events
- reconnect scheduling
- synthetic gap detection
- driver stop reasons
crates/kasgraph-rpc/examples/continuous_wrpc_smoke.rsnow supports wall-clock soak runs with:KASGRAPH_WRPC_DURATION_SECONDS- optional
KASGRAPH_WRPC_MAX_MESSAGES=0for duration-only stop - compact summary output including
highest_daa_seen, reconnect count, connection count, and stop reason
- Added regression coverage:
continuous_subscription_emits_driver_events_for_reconnects
- Verified live with:
KASGRAPH_WRPC_DURATION_SECONDS=10 KASGRAPH_WRPC_MAX_MESSAGES=0 cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- Observed real soak result against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 100 notifications in 10 seconds
highest_daa_seen=444248915reconnects=0connections=1
- Verification after the change:
cargo fmt && cargo test -p kasgraph-rpc- full
cargo test
- This gives the next session a much better baseline for longer live-node soak comparisons.
- Fixed a real continuous-driver edge case in
kasgraph-rpc: after reconnect, stale replay at or below the previous DAA watermark no longer clears the pending gap check. - Gap detection now waits for the first actually new DAA above the prior watermark, which also fixes overlapping
VirtualChainChangedreconnect payloads where the notification includes both old and new DAA scores. - Added regression tests:
continuous_subscription_keeps_gap_check_pending_across_stale_replay_after_reconnectcontinuous_subscription_detects_gap_when_virtual_chain_delta_overlaps_old_daa
- Verification after the change:
cargo fmt && cargo test -p kasgraph-rpc- full
cargo test
- This is a meaningful Phase 2.3 hardening step: reconnects shaped like replay + jump no longer silently suppress synthetic recovery.
- Added
crates/kasgraph-rpc/examples/continuous_wrpc_smoke.rs, which exercises the samespawn_continuous_subscription(...)reconnect-capable driver used by the node, but without requiring Postgres. - Verified it live with:
KASGRAPH_WRPC_MAX_MESSAGES=6 cargo run -p kasgraph-rpc --example continuous_wrpc_smoke
- That continuous smoke captured a mixed real stream from
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json:- 5
BlockAdded - 1
VirtualChainChanged - 0
RecoveryRequired
- 5
- This is important because it proves the in-tree continuous driver can hold a real public mainnet stream end-to-end now that TLS/provider setup is correct.
- README / STATUS / NEXT_SESSION / RPC reference were updated again to point the next agent at the new smoke path and the remaining reconnect/recovery goals.
- Added
crates/kasgraph-rpc/examples/live_wrpc_smoke.rsfor repeatable public-node validation without ad hoc scripts. - The repo now explicitly installs a rustls crypto provider before websocket connects, which fixed a real runtime blocker:
wss://had previously failed withTLS support not compiled inand then with rustlsCryptoProviderpanics. cargo run -p kasgraph-rpc --example live_wrpc_smokenow works againstwss://eric.kaspa.stream/kaspa/mainnet/wrpc/jsonfrom this environment.- That live smoke captured real notifications from mainnet, including both:
BlockAddedVirtualChainChanged(empty add/remove delta in the observed sample)
kasgraph-nodecapability-gating was factored into a dedicated helper and now has direct unit coverage for rejecting:rpcApiVersion < 1- missing
hasMessageId - missing
hasNotifyCommand
- Verification after the change:
cargo fmtcargo test -p kasgraph-rpc -p kasgraph-nodecargo run -p kasgraph-rpc --example live_wrpc_smoke- full
cargo test
- This closes another real gap: repo-local live validation no longer depends on external scratch scripts, and public
wss://endpoints now actually work through the in-tree client.
kasgraph-rpcnow exposesprobe_live_capabilities(), which callsgetServerInfoplusgetInfoagainst the configured endpoint and returns parsed capability data.- New parsed structs landed in
kasgraph-rpc:ServerInfo,NodeInfo, andLiveRpcCapabilities. kasgraph-nodecontinuous mode now runs that probe before subscribing and bails early if the node does not advertise:rpcApiVersion >= 1hasMessageId = truehasNotifyCommand = true
- Unsynced nodes are now surfaced as a warning during preflight instead of being silently accepted.
- New regression tests:
probe_live_capabilities_reads_http_endpointprobe_live_capabilities_reads_wrpc_json_endpoint
cargo test -p kasgraph-rpc -p kasgraph-nodeand fullcargo testare green after the capability-probe change.- This tightens the live path meaningfully: continuous ingestion now fails fast on incompatible public nodes instead of getting further into subscribe/recovery flows before exploding.
- Follow-up live probing against
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/jsonconfirmed that point calls likegetBlock,getBlockDagInfo, andgetVirtualChainFromBlockwork over the same JSON wRPC websocket path, not justgetServerInfo/getInfoand subscriptions. crates/kasgraph-rpc/src/lib.rsnow detectsws:///wss://endpoint URLs for point RPC and performs a one-shot JSON wRPC request instead of assuming HTTP POST.- The wRPC point-response shape is normalized so existing parsers can consume live responses that come back as
{"method":"getBlock", "params": {...}}rather than HTTP-style{"result": {...}}. - New regression tests:
fetch_block_supports_wrpc_json_endpointrecover_blocks_in_daa_range_supports_wrpc_json_endpoint
cargo test -p kasgraph-rpcand fullcargo testare green after the ws point-RPC fallback change.- This closes a real Phase 2.3 gap: the confirmed public node can now support subscription, hash hydration, and anchor-based recovery through the same public websocket endpoint instead of needing a separate HTTP JSON-RPC URL.
- A reachable public mainnet node was confirmed:
wss://eric.kaspa.stream/kaspa/mainnet/wrpc/json. - Live probing showed
getServerInfoandgetInfoboth succeed there, while the earlier guessednotifyBlockAdded/notifyVirtualChainChangedmethods fail withRPC method not found. - Upstream client source (
kaspa-wrpc-client) was checked and confirmed that live notifications use genericsubscribe/unsubscribeRPC ops carrying a serializedScopepayload, notnotify*RPC methods. - Live probing then confirmed the actual JSON wire shape:
- subscribe request:
{"method":"subscribe","params":{"BlockAdded":{}}} - virtual-chain subscribe request:
{"method":"subscribe","params":{"VirtualChainChanged":{"include_accepted_transaction_ids":false}}} - subscribe ack:
{"method":"subscribe","params":{"id":...}} - live notifications:
blockAddedNotification/virtualChainChangedNotificationwith nestedparams.BlockAdded/params.VirtualChainChangedpayloads
- subscribe request:
crates/kasgraph-rpc/src/lib.rswas updated to use those real subscribe payloads, recognize lowercase*Notificationmethod names, and unwrap the nested scope payload before parsing.cargo test -p kasgraph-rpcand fullcargo testare green after the live-wire-format change.- Resolver/public-node discovery is still noisy from this environment (
403/404/523/ SSL mismatch on other candidates), but true wire validation is no longer blocked.
MultiRpcClientnow exposesrecover_blocks_in_daa_range(start_hash, from, to), which callsgetVirtualChainFromBlock, parsesremovedChainBlockHashes/addedChainBlockHashes, fetches the added blocks, and filters them to the requested DAA window before re-emitting aVirtualChainChangednotification.IngestionState::recovery_anchor_hash(from_daa)derives the highest locally known pre-gap block hash from committed + probabilistic state.run_continuous_ingestionnow prefers anchor-based recovery forRecoveryRequiredevents and only falls back toKASGRAPH_GAP_RECOVERY_BLOCK_HASHESwhen no local anchor can be derived.- New tests:
recover_blocks_in_daa_range_uses_virtual_chain_deltainkasgraph-rpcandrecovery_anchor_hash_prefers_highest_known_block_below_gap_startinkasgraph-node. cargo test -p kasgraph-rpc -p kasgraph-nodeis green.
run_continuous_ingestionnow spawnsMultiRpcClient::spawn_health_probe_loopalongside the subscription driver, soendpoint_health()stays fresh while the wRPC subscription is the only active traffic.- The probe handle is explicitly aborted on exit (the loop has no built-in shutdown signal); without that the task would keep firing every interval until process exit.
- No test surface change — the probe loop is a background timer best validated via integration tests. The build remained green at 59 tests before the unrelated
kasgraph-streamfailure below.
apply_and_persist_notificationnow returnsNotificationOutcome { recovery_requested, committed_count }so the continuous loop can react to gap announcements.- New
KASGRAPH_GAP_RECOVERY_BLOCK_HASHESenv (parsed intoContinuousConfig.gap_recovery_block_hashes) feeds runtime gap recovery; distinct from the existingKASGRAPH_RECOVERY_BLOCK_HASHESwhich only drives bootstrap replay. - When
outcome.recovery_requestedisSome((from, to))AND the hash list is non-empty AND a client is available, the loop callsMultiRpcClient::recover_blocks_by_hashes(hashes, from, to)and re-applies the resultingVirtualChainChangedthrough the same persist helper. A second-level recovery is intentionally not chased — one-level guard prevents recovery storms. - When the hash list is empty, the gap is logged with a clear "skipping active recovery" warning so operators know what to set.
- Two new node tests:
continuous_config_defaults_match_documented_valuesextended to assert gap hashes default empty;notification_outcome_default_indicates_no_recovery_and_no_writespins the outcome shape. - 59 tests total.
BLOCKDAG_REORG_SEMANTICS.mdgains an "Active gap recovery in continuous mode" subsection; STATUS.md updated.
- New private
DriverState { last_emitted_daa, pending_gap_check }lives inrun_continuous_subscriptionand survives across reconnects. pending_gap_checkis set after every reconnect (transport error or clean disconnect) but never on the initial connect.- When set, the next DAA-bearing notification triggers a check: if its lowest DAA is more than one beyond the last emitted DAA, the driver sends a synthetic
RecoveryRequired { from_daa_score: last + 1, to_daa_score: first - 1, reason: "subscription gap after reconnect…" }onto the same channel before forwarding the actual notification. first_daa_of/max_daa_ofhelpers handle the per-variant payload shape;RecoveryRequiredcarries no DAA and does not advancelast_emitted_daa.- Two new rpc tests: the skip-DAA case emits the synthetic recovery in correct order; the contiguous case emits no synthetic recovery. The receiver-drop, max-attempts, and reconnect-with-contiguous-batch tests all still pass.
BLOCKDAG_REORG_SEMANTICS.mdgains a "Gap detection at reconnect" subsection.- 58 tests total. The downstream node
IngestionStatealready handlesRecoveryRequired(rolls back probabilistic in range, surfacesrecovery_requested), so no consumer changes were needed.
- The per-notification persist work (apply → unwind → POI re-anchor → POI/audit/committed-block writes) is now a single
apply_and_persist_notificationhelper called by both the bootstrap and continuous paths. - New
IngestMode { Bootstrap, Continuous }selected viaKASGRAPH_INGEST_MODE(defaults tobootstrap; unknown values warn and fall back). - New
ContinuousConfigwired from env:KASGRAPH_NOTIFICATION_WS_URL,KASGRAPH_NOTIFICATION_SOURCE_LABEL,KASGRAPH_CONTINUOUS_MAX_MESSAGES(0 = forever),KASGRAPH_CONTINUOUS_CHANNEL_CAPACITY,KASGRAPH_CONTINUOUS_BACKOFF_INITIAL_MS/_MAX_MS/_MULTIPLIER/_MAX_ATTEMPTS. run_continuous_ingestionspawnsMultiRpcClient::spawn_continuous_subscription, consumes frommpsc::Receiverin atokio::select!againsttokio::signal::ctrl_c(), applies each notification through the shared helper, exits cleanly on Ctrl-C / max-messages / driver channel close, drops the receiver, and awaits the driver handle.- Three new node tests cover the IngestMode default, ContinuousConfig defaults, and the missing-ws-url validation bail. The continuous-config preflight is factored into
validate_continuous_configso tests don't need a live Store. - 56 tests total.
BLOCKDAG_REORG_SEMANTICS.mdmarks the continuous wRPC subscription as fully landed.
SubscriptionBackoff { initial_delay, max_delay, multiplier, max_attempts }config struct (with sensibleDefault).MultiRpcClient::spawn_continuous_subscription(url, served_by, sender, backoff) -> JoinHandle<()>runs a long-lived driver that subscribes, parses, and forwards eachChainNotificationonto anmpsc::Sender. Exponential backoff on transport errors; backoff resets on clean disconnect; cooperative shutdown viatokio::select!onsender.closed()even when blocked onread.next(); gives up aftermax_attempts(0 = forever).- Three new tests against new mock helpers (
spawn_mock_ws_server_multi,spawn_mock_ws_server_idle): reconnect after server-side disconnect delivers both batches; receiver-drop mid-stream exits the driver; unreachable URL plusmax_attempts = 2exits the driver promptly. - 53 tests total.
BLOCKDAG_REORG_SEMANTICS.mdupdated to mark the continuous primitive as landed, with node-side wiring as the next jump.
Store::latest_poi_for_subgraph(subgraph) -> Option<PoiCheckpoint>returns the highest-DAA surviving POI row.IngestionState::reseed_prior_poi(prior_poi)sets the in-memory hash chain anchor.kasgraph-nodestartup path now loads the latest POI for the configured subgraph and re-seedsIngestionState.prior_poifrom it — restarts continue the same hash chain.- After each committed unwind, the node loop re-loads the latest POI and re-seeds
prior_poifrom the new survivor (or[0u8; 32]if nothing survives) so the next committed block hashes from the survivor, not the deleted block. - Two new node-side tests confirm: a re-seeded chain produces the same POI as a natural continuation; re-seeding to the default zero anchor restarts genesis-style.
- 50 tests total, all green.
BLOCKDAG_REORG_SEMANTICS.md"what the scaffold does" table now lists POI re-anchoring as "yes".
- New migration
20260526150000_committed_unwind.sqladdskasgraph_committed_block(per-subgraph hash → daa/served_by index) andkasgraph_reorg_audit(per-unwind record with removed-hash array, reason, timing). Store::record_committed_blockandStore::unwind_committed_blocks_for_subgraphland. Unwind runs in one SQL transaction: lookup committed rows → delete matching POI + audit + committed-block rows → insert reorg audit row → returnCommittedUnwindReport { removed_hashes, audit_id }.IngestionStategainsremove_committed_by_hashesand surfacescommitted_unwinds: Vec<BootstrapBlock>on the transition struct.kasgraph-nodecalls the Store unwind whenever the transition reports any.- Node persistence loop now also writes
kasgraph_committed_blockrows alongside POI + audit so the unwind has something to delete. - Two new node-side tests:
virtual_chain_changed_surfaces_committed_unwinds_for_committed_removalsandblock_added_notification_does_not_emit_committed_unwinds. Migrator test updated to expect 2 migrations. BLOCKDAG_REORG_SEMANTICS.md"what the scaffold does" table updated.
- Workspace scaffold landed (cargo + npm workspaces, CI, vitest).
- Seven Rust crates compile;
kasgraph-poiships real logic + unit tests (blake2b-256 hash chain). kasgraph-rpcnow has an initial real multi-RPC client: primary-first failover, rotating backup order, health probes, background probe-loop helper, and in-memory block audit records with regression tests.kasgraph-storenow has its first real migration slice plus a liveStoreAPI for covenant lineage heads/rows, POI checkpoints, RPC audit inserts, and per-subgraph schema bootstrap.kasgraph-nodenow uses that store in a bootstrap path: ifKASGRAPH_DATABASE_URLis set, it runs migrations, ensures the subgraph schema, processes minimal live-style notifications, fetches one or more real blocks throughkasgraph-rpcwhenKASGRAPH_RPC_PRIMARY_URLis configured, buffers probabilistic blocks separately from committed blocks, rolls back conflicting probabilistic ranges, can request a small recovery replay window, computes scaffold POI hashes for committed blocks, and writes POI plus RPC audit rows.kasgraph-rpcnow exposes a stronger notification/recovery surface:ChainNotification, orderedfetch_blocks,recover_blocks_by_hashes, JSONL parsing, websocket subscription bootstrap, upstream-style notification-envelope parsing, virtual-chain hash hydration, idle-bounded websocket reads (max_messages = 0supported for unbounded capture), and fail-fast subscription rejection errors.- Eight MCP tool names enumerated; manifest type covers all five Kaspa-native data-source kinds.
- Phase 1 reference docs all substantive:
KIP20_COVENANT_ID_QUERIES.md,KASPA_RPC_REFERENCE.md,THEGRAPH_REFERENCE.md,BLOCKDAG_REORG_SEMANTICS.md(KIP-20 finality + ordered Postgres unwind + POI re-anchoring), andKRC20_KRC721_REFERENCE.md(legacy Kasplex + native KCC20 + native KRC-721). kasgraph-detectorsis no longer a single-file scaffold:fingerprint.rsdefinesFingerprint/MaskedWindowwith masked-byte matching + field-named extraction;registry.rsdeclares 12 OpenSilver core patterns + 5 KCC20 variants with0xFE-prefixed placeholder discriminators. 17 unit tests pin the engine and reject cross-pattern collisions.- Phase 0 ecosystem coordination is intentionally skipped per user direction.
The Graph compatibility deep dive is now landed in docs/references/THEGRAPH_REFERENCE.md.
What landed:
- Minimal committed-vs-probabilistic ingestion state in
kasgraph-node. - Minimal live-style notification model in
kasgraph-rpc/kasgraph-node(BlockAdded,VirtualChainChanged,RecoveryRequired). - JSONL notification parsing in
kasgraph-rpc, withKASGRAPH_NOTIFICATION_JSONLsupport inkasgraph-nodeso structured event streams can be injected directly. - Real websocket subscription bootstrap in
kasgraph-rpcvia genericsubscribepayloads forBlockAddedandVirtualChainChanged, matching live mainnet wRPC behavior. - Parsing of upstream-style event envelopes, including real live
blockAddedNotification/virtualChainChangedNotificationwrappers with nested scope payloads. - Virtual-chain hydration in
kasgraph-rpcso hash-onlyvirtualChainChangedwebsocket payloads are resolved back into fetched blocks. - Idle-bounded websocket reads in
kasgraph-rpc, withmax_messages = 0meaning unbounded capture andKASGRAPH_NOTIFICATION_IDLE_TIMEOUT_MSavailable inkasgraph-nodeto stop quiet streams cleanly. - Explicit websocket subscription error handling in
kasgraph-rpc, so server-side rejections no longer look like an empty or quiet stream. - Promotion of buffered probabilistic blocks when a finalized block arrives.
- Rollback of conflicting probabilistic ranges before replay.
- Small replay helper in
kasgraph-rpcfor env-driven recovery ranges.
What still needs to land:
- Active recovery-path validation: the 60s / 5m / 15m passive soaks are all clean, so the next best move is no longer “wait longer” — it is to force or capture reconnects deliberately. Add optional newline-delimited event-log output from the smoke runner and/or a controlled reconnect/fault-injection harness around
spawn_continuous_subscription_with_events(...)so gap detection and recovery can be validated against real trace shapes instead of hoping the public node flakes. - Live-node validation of anchor-based recovery: now that point RPC over JSON wRPC is wired, use those forced/captured reconnect traces to inspect the exact
getVirtualChainFromBlockresponses and confirm the new recovery path behaves correctly across reconnects and deeper selected-chain churn. - Next likely code move: extend
continuous_wrpc_smokewith optional NDJSON event output (driver events + notification summaries) and then add a targeted integration test or mock harness that simulates reconnect + stale replay + overlap patterns while persisting the resulting trace artifact.
The first schema is now in crates/kasgraph-store/migrations/20260526110500_initial_kip20_lineage.sql and includes:
kasgraph_covenant_lineage_headkasgraph_covenant_lineage_rowkasgraph_poikasgraph_rpc_block_audit
Next finishers for this slice:
- Add
sqlx::testcoverage once a Postgres test database is wired. - Call
Store::migrate()and persistence methods fromkasgraph-node. - Replace in-memory RPC audit retention with store-backed writes.
The fingerprint engine and per-pattern registry are live. What remains:
- Extend the OpenSilver manifest pipeline (
artifacts/manifests/) to emit a per-patterncompiledScriptBytes(hex) +stateLayout(field name → offset/len) entry. Todayide-all.jsoncarries metadata but not bytes. - Add a
cargo xtask sync-opensilver-fingerprintstask in this repo that ingests that JSON and rewritescrates/kasgraph-detectors/src/registry.rs's entry bodies. The currentopensilver()builder is exactly the shape the sync should produce. - After sync, the cross-pattern non-collision test will run against real bytes; if any two patterns collide, that is a real bug in the upstream compile pipeline (typically a missed entry-point discriminator).
- Skipped variants —
ZkVerifiedComputation,ZkPrivateAssetTransfer,ZkVerifiedOracle,ZkVerifiedOracleV2,ZkProofStitchedMultiPattern,Krc721Collection,Krc721Nft,KasBondsBond— need their own registry entries once OpenSilver Phase 5 artifacts and the KRC-721 spec land.
kasgraph-poi is already real. Wire it into kasgraph-node so even the scaffold ingestion writes one POI per block to a kasgraph_poi Postgres table.
crates/kasgraph-rpc/src/lib.rsis no longer a pure stub. It already exposesfetch_block,probe_health_once,spawn_health_probe_loop,endpoint_health, andaudit_log.- The current client speaks JSON-RPC over HTTP with
getBlockandgetBlockDagInfopayloads, and now also has an initial websocket subscription bootstrap fornotifyBlockAdded/notifyVirtualChainChanged. crates/kasgraph-store/src/lib.rsnow embeds migrations viasqlx::migrate!and validatesSubgraphIdto keep dynamic schema creation safe.crates/kasgraph-node/src/main.rsnow has a real async bootstrap path keyed offKASGRAPH_DATABASE_URL,KASGRAPH_SUBGRAPH,KASGRAPH_BLOCK_HASHES(or singleKASGRAPH_BLOCK_HASH),KASGRAPH_REMOVED_BLOCK_HASHES,KASGRAPH_RECOVERY_BLOCK_HASHES,KASGRAPH_RECOVERY_RANGE,KASGRAPH_NOTIFICATION_JSONL,KASGRAPH_NOTIFICATION_WS_URL,KASGRAPH_NOTIFICATION_SOURCE_LABEL, optionalKASGRAPH_NOTIFICATION_IDLE_TIMEOUT_MS, and RPC env vars such asKASGRAPH_RPC_PRIMARY_URL/KASGRAPH_RPC_BACKUP_URLS.crates/kasgraph-rpc/src/lib.rsnow has the first public notification abstraction for live subscription code, a parser for line-delimited JSON event feeds, websocket subscribe-message bootstrap, envelope parsing that accepts both scaffold-stylekindpayloads and upstream-style websocket event wrappers, and fast-fail handling for explicit subscription rejection payloads.- The current POI bytes are still derived from block metadata only (
hash,daa,blue,finalized,served_by). That is good enough for scaffold wiring, not for final indexing correctness. - The current reorg handling is intentionally limited: committed conflicts are logged and ignored until deeper rollback support lands; only probabilistic ranges are actively rolled back.
- Existing tests use fast unit coverage only. No live Postgres fixture is wired yet, so the next useful jump is
sqlx::testonceDATABASE_URLor a dedicated test setup exists.
- General public-node discovery is still flaky from this environment. One live node is confirmed reachable (
eric.kaspa.stream) and now usable for both subscriptions and point RPC, but other resolver/public-node candidates were still returning 403/404/523 or SSL mismatch responses, so there is not yet a robust multi-node discovery path for broader live validation.
- Phase 1.3 — Kasplex indexer + krc721.stream open-source-code review. Pull patterns that work at Kaspa scale; flag what KasGraph should improve. The legacy-KRC-20 acceptance rules in
KRC20_KRC721_REFERENCE.mdshould be validated block-for-block against Kasplex's mainnet output as soon as the legacy ingest path lands. - Phase 3.1 prep — GraphQL gateway server choice (Apollo / Yoga / Mercurius). Stub
@kasgraph/apiis ready for the choice. - Committed-state SQL unwind — implement the ordered rollback procedure described in
BLOCKDAG_REORG_SEMANTICS.mdonce the next migration slice (per-block acceptance index +kasgraph_reorg_audit) lands.
- Phase 0 outreach (Kaspa Foundation, Kasplex, kas.fyi, krc721.stream, Michael Sutton, Hans Moog, wallet teams).
- Hosted-service infrastructure (Phase 5 — cloud provider, Postgres deploy shape, kasgraph.com DNS / TLS).
- Push the repo to a GitHub remote.
PLAN.md— source of truth for every phase.STATUS.md— live status block updated after each commit arc.docs/references/— Phase 1 reference docs.- Sibling
OpenSilverrepo — source of pattern fingerprints forkasgraph-detectors.