Implement amduatd v2 vertical slice and integration harness

This commit is contained in:
Carl Niklas Rydberg 2026-02-07 20:11:15 +01:00
commit 7c794d8bab
15 changed files with 1922 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# Local daemon/store runtime state
.amduat-asl/
amduatd.sock
.cursor
# Local configuration overrides
config/env.local
# Logs and temp files
*.log
*.tmp
*.swp
*~
# Test/runtime artifacts
/tmp/
# OS/editor metadata
.DS_Store
Thumbs.db
.vscode/
.idea/

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# amduat-app-next (Scaffold)
Starter project scaffold for building an app against `amduatd` v2.
## Included handoff assets
- `docs/v2-app-developer-guide.md` (compact app guide)
- `contracts/amduatd-api-contract.v2.json` (machine contract)
- `scripts/graph_client_helpers.sh` (reusable shell helpers)
## Quick start
1. Configure environment:
```sh
cp config/env.example config/env.local
```
2. Run startup checks against a running `amduatd` socket:
```sh
./scripts/bootstrap_check.sh
```
3. Run sample idempotent batch ingest:
```sh
./scripts/ingest_example.sh
```
4. Run sample changes sync loop:
```sh
./scripts/sync_loop.sh
```
## v2 Vertical Slice CLI
Use the integrated v2 app flow wrapper:
```sh
./scripts/v2_app.sh startup-check
./scripts/v2_app.sh ingest '{"idempotency_key":"k1","mode":"continue_on_error","nodes":[{"name":"doc:1"}]}'
./scripts/v2_app.sh sync-once
./scripts/v2_app.sh retrieve 'doc:1' 'ms.within_domain'
./scripts/v2_app.sh tombstone '<edge_ref>'
```
Run integration coverage (requires running `amduatd` + `jq`):
```sh
./tests/integration_v2.sh
```
## Notes
- This scaffold assumes local Unix-socket access to `amduatd`.
- Graph cursors are opaque and must be persisted exactly as returned.
- Keep `contracts/amduatd-api-contract.v2.json` in sync with upstream when you pull updates.

18
config/env.example Normal file
View file

@ -0,0 +1,18 @@
# Copy to config/env.local and edit as needed
SOCK="../amduatd.sock"
BASE="http://localhost"
SPACE="app1"
# Incremental sync configuration
SYNC_LIMIT="200"
SYNC_WAIT_MS="15000"
CURSOR_FILE=".cursor"
# Retry policy (applies to retryable server/internal failures)
RETRY_MAX_ATTEMPTS="4"
RETRY_INITIAL_MS="200"
RETRY_MAX_MS="2000"
# Curl timeouts
CURL_CONNECT_TIMEOUT_SECONDS="2"
CURL_MAX_TIME_SECONDS="30"

View file

@ -0,0 +1,820 @@
{
"contract": "AMDUATD/API/2",
"base_path": "/v2",
"notes": "Draft v2: PEL-only write surface. Direct artifact write endpoint removed.",
"endpoints": [
{"method": "GET", "path": "/v2/meta"},
{"method": "HEAD", "path": "/v2/meta"},
{"method": "GET", "path": "/v2/contract"},
{"method": "GET", "path": "/v2/healthz"},
{"method": "GET", "path": "/v2/readyz"},
{"method": "GET", "path": "/v2/metrics"},
{"method": "GET", "path": "/v2/artifacts/{ref}"},
{"method": "HEAD", "path": "/v2/artifacts/{ref}"},
{"method": "GET", "path": "/v2/artifacts/{ref}?format=info"},
{"method": "POST", "path": "/v2/pel/execute"},
{"method": "POST", "path": "/v2/ops/put"},
{"method": "POST", "path": "/v2/ops/concat"},
{"method": "POST", "path": "/v2/ops/slice"},
{"method": "GET", "path": "/v2/jobs/{id}"},
{"method": "GET", "path": "/v2/get/{ref}"},
{"method": "POST", "path": "/v2/graph/nodes"},
{"method": "POST", "path": "/v2/graph/nodes/{name}/versions"},
{"method": "POST", "path": "/v2/graph/nodes/{name}/versions/tombstone"},
{"method": "GET", "path": "/v2/graph/nodes/{name}/versions"},
{"method": "GET", "path": "/v2/graph/nodes/{name}/neighbors"},
{"method": "GET", "path": "/v2/graph/search"},
{"method": "GET", "path": "/v2/graph/paths"},
{"method": "GET", "path": "/v2/graph/subgraph"},
{"method": "POST", "path": "/v2/graph/edges"},
{"method": "POST", "path": "/v2/graph/edges/tombstone"},
{"method": "POST", "path": "/v2/graph/batch"},
{"method": "POST", "path": "/v2/graph/query"},
{"method": "POST", "path": "/v2/graph/retrieve"},
{"method": "POST", "path": "/v2/graph/export"},
{"method": "POST", "path": "/v2/graph/import"},
{"method": "GET", "path": "/v2/graph/schema/predicates"},
{"method": "POST", "path": "/v2/graph/schema/predicates"},
{"method": "GET", "path": "/v2/graph/stats"},
{"method": "GET", "path": "/v2/graph/capabilities"},
{"method": "GET", "path": "/v2/graph/changes"},
{"method": "GET", "path": "/v2/graph/edges"},
{"method": "GET", "path": "/v2/graph/nodes/{name}"},
{"method": "GET", "path": "/v2/graph/history/{name}"}
],
"schemas": {
"job_enqueue_response": {
"type": "object",
"required": ["job_id", "status"],
"properties": {
"job_id": {"type": "integer"},
"status": {"type": "string"}
}
},
"job_status_response": {
"type": "object",
"required": ["job_id", "kind", "status", "created_at_ms"],
"properties": {
"job_id": {"type": "integer"},
"kind": {"type": "string"},
"status": {"type": "string"},
"created_at_ms": {"type": "integer"},
"started_at_ms": {"type": ["integer", "null"]},
"completed_at_ms": {"type": ["integer", "null"]},
"result_ref": {"type": ["string", "null"]},
"error": {"type": ["string", "null"]}
}
},
"healthz_response": {
"type": "object",
"required": ["ok", "status", "time_ms"],
"properties": {
"ok": {"type": "boolean"},
"status": {"type": "string"},
"time_ms": {"type": "integer"}
}
},
"readyz_response": {
"type": "object",
"required": ["ok", "status", "components"],
"properties": {
"ok": {"type": "boolean"},
"status": {"type": "string"},
"components": {
"type": "object",
"required": ["graph_index", "federation"],
"properties": {
"graph_index": {"type": "boolean"},
"federation": {"type": "boolean"}
}
}
}
},
"put_request": {
"type": "object",
"required": ["body_hex"],
"properties": {
"body_hex": {"type": "string"},
"type_tag": {"type": "string"}
}
},
"concat_request": {
"type": "object",
"required": ["left_ref", "right_ref"],
"properties": {
"left_ref": {"type": "string"},
"right_ref": {"type": "string"}
}
},
"slice_request": {
"type": "object",
"required": ["ref", "offset", "length"],
"properties": {
"ref": {"type": "string"},
"offset": {"type": "integer"},
"length": {"type": "integer"}
}
},
"graph_node_create_request": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"},
"ref": {"type": "string", "description": "optional initial published ref"}
}
},
"graph_node_create_response": {
"type": "object",
"required": ["name", "concept_ref"],
"properties": {
"name": {"type": "string"},
"concept_ref": {"type": "string"}
}
},
"graph_provenance": {
"type": "object",
"required": ["source_uri", "extractor", "observed_at", "ingested_at", "trace_id"],
"properties": {
"source_uri": {"type": "string"},
"extractor": {"type": "string"},
"confidence": {"type": ["string", "number", "integer"]},
"observed_at": {"type": "integer"},
"ingested_at": {"type": "integer"},
"license": {"type": "string"},
"trace_id": {"type": "string"}
}
},
"graph_edge_create_request": {
"type": "object",
"required": ["subject", "predicate", "object"],
"properties": {
"subject": {"type": "string", "description": "concept name or hex ref"},
"predicate": {"type": "string", "description": "relation alias/name or hex ref"},
"object": {"type": "string", "description": "concept name or hex ref"},
"metadata_ref": {"type": "string", "description": "optional artifact ref"},
"provenance": {"$ref": "#/schemas/graph_provenance"}
}
},
"graph_edge_create_response": {
"type": "object",
"required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"],
"properties": {
"subject_ref": {"type": "string"},
"predicate_ref": {"type": "string"},
"object_ref": {"type": "string"},
"edge_ref": {"type": "string"},
"metadata_ref": {"type": "string"}
}
},
"graph_edge_tombstone_request": {
"type": "object",
"required": ["edge_ref"],
"properties": {
"edge_ref": {"type": "string"},
"metadata_ref": {"type": "string"},
"provenance": {"$ref": "#/schemas/graph_provenance"}
}
},
"graph_edge_tombstone_response": {
"type": "object",
"required": ["ok", "target_edge_ref", "tombstone_edge_ref"],
"properties": {
"ok": {"type": "boolean"},
"target_edge_ref": {"type": "string"},
"tombstone_edge_ref": {"type": "string"},
"metadata_ref": {"type": "string"}
}
},
"graph_node_version_tombstone_request": {
"type": "object",
"required": ["ref"],
"properties": {
"ref": {"type": "string"},
"metadata_ref": {"type": "string"},
"provenance": {"$ref": "#/schemas/graph_provenance"}
}
},
"graph_node_version_tombstone_response": {
"type": "object",
"required": ["ok", "name", "ref", "target_edge_ref", "tombstone_edge_ref"],
"properties": {
"ok": {"type": "boolean"},
"name": {"type": "string"},
"ref": {"type": "string"},
"target_edge_ref": {"type": "string"},
"tombstone_edge_ref": {"type": "string"},
"metadata_ref": {"type": "string"}
}
},
"graph_batch_request": {
"type": "object",
"properties": {
"idempotency_key": {"type": "string"},
"mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]},
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string"},
"ref": {"type": "string"}
}
}
},
"versions": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "ref"],
"properties": {
"name": {"type": "string"},
"ref": {"type": "string"},
"metadata_ref": {"type": "string"},
"provenance": {"$ref": "#/schemas/graph_provenance"}
}
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": ["subject", "predicate", "object"],
"properties": {
"subject": {"type": "string"},
"predicate": {"type": "string"},
"object": {"type": "string"},
"metadata_ref": {"type": "string"},
"provenance": {"$ref": "#/schemas/graph_provenance"}
}
}
}
}
},
"graph_batch_response": {
"type": "object",
"required": ["ok", "applied", "results"],
"properties": {
"ok": {"type": "boolean"},
"idempotency_key": {"type": "string"},
"mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]},
"applied": {
"type": "object",
"required": ["nodes", "versions", "edges"],
"properties": {
"nodes": {"type": "integer"},
"versions": {"type": "integer"},
"edges": {"type": "integer"}
}
},
"results": {
"type": "array",
"items": {
"type": "object",
"required": ["kind", "index", "status", "code", "error"],
"properties": {
"kind": {"type": "string", "enum": ["node", "version", "edge"]},
"index": {"type": "integer"},
"status": {"type": "string", "enum": ["applied", "error"]},
"code": {"type": "integer"},
"error": {"type": ["string", "null"]}
}
}
}
}
},
"graph_query_request": {
"type": "object",
"properties": {
"where": {
"type": "object",
"properties": {
"subject": {"type": "string"},
"object": {"type": "string"},
"node": {"type": "string"},
"provenance_ref": {"type": "string"}
}
},
"predicates": {"type": "array", "items": {"type": "string"}},
"direction": {"type": "string", "enum": ["any", "outgoing", "incoming"]},
"include_versions": {"type": "boolean"},
"include_tombstoned": {"type": "boolean"},
"include_stats": {"type": "boolean"},
"max_result_bytes": {"type": "integer"},
"as_of": {"type": ["string", "integer"]},
"limit": {"type": "integer"},
"cursor": {"type": ["string", "integer"]}
}
},
"graph_query_response": {
"type": "object",
"required": ["nodes", "edges", "paging"],
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["concept_ref", "name", "latest_ref"],
"properties": {
"concept_ref": {"type": "string"},
"name": {"type": ["string", "null"]},
"latest_ref": {"type": ["string", "null"]},
"versions": {
"type": "array",
"items": {
"type": "object",
"required": ["edge_ref", "ref"],
"properties": {
"edge_ref": {"type": "string"},
"ref": {"type": "string"}
}
}
}
}
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"],
"properties": {
"subject_ref": {"type": "string"},
"predicate_ref": {"type": "string"},
"object_ref": {"type": "string"},
"edge_ref": {"type": "string"}
}
}
},
"paging": {
"type": "object",
"required": ["next_cursor", "has_more"],
"properties": {
"next_cursor": {"type": ["string", "null"]},
"has_more": {"type": "boolean"}
}
},
"stats": {
"type": "object",
"properties": {
"scanned_edges": {"type": "integer"},
"returned_edges": {"type": "integer"}
}
}
}
},
"graph_retrieve_request": {
"type": "object",
"required": ["roots"],
"properties": {
"roots": {"type": "array", "items": {"type": "string"}},
"goal_predicates": {"type": "array", "items": {"type": "string"}},
"max_depth": {"type": "integer"},
"max_fanout": {"type": "integer"},
"include_versions": {"type": "boolean"},
"include_tombstoned": {"type": "boolean"},
"as_of": {"type": ["string", "integer"]},
"provenance_min_confidence": {"type": ["string", "number", "integer"]},
"limit_nodes": {"type": "integer"},
"limit_edges": {"type": "integer"},
"max_result_bytes": {"type": "integer"}
}
},
"graph_retrieve_response": {
"type": "object",
"required": ["nodes", "edges", "explanations", "truncated", "stats"],
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["concept_ref", "name", "latest_ref"],
"properties": {
"concept_ref": {"type": "string"},
"name": {"type": ["string", "null"]},
"latest_ref": {"type": ["string", "null"]},
"versions": {
"type": "array",
"items": {
"type": "object",
"required": ["edge_ref", "ref"],
"properties": {
"edge_ref": {"type": "string"},
"ref": {"type": "string"}
}
}
}
}
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"],
"properties": {
"subject_ref": {"type": "string"},
"predicate_ref": {"type": "string"},
"object_ref": {"type": "string"},
"edge_ref": {"type": "string"}
}
}
},
"explanations": {
"type": "array",
"items": {
"type": "object",
"required": ["edge_ref", "depth", "reasons"],
"properties": {
"edge_ref": {"type": "string"},
"depth": {"type": "integer"},
"reasons": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": ["number", "null"]}
}
}
},
"truncated": {"type": "boolean"},
"stats": {
"type": "object",
"properties": {
"scanned_edges": {"type": "integer"},
"traversed_edges": {"type": "integer"},
"returned_nodes": {"type": "integer"},
"returned_edges": {"type": "integer"}
}
}
}
},
"graph_export_request": {
"type": "object",
"properties": {
"as_of": {"type": ["string", "integer"]},
"cursor": {"type": ["string", "integer"]},
"limit": {"type": "integer"},
"predicates": {"type": "array", "items": {"type": "string"}},
"roots": {"type": "array", "items": {"type": "string"}},
"include_tombstoned": {"type": "boolean"},
"max_result_bytes": {"type": "integer"}
}
},
"graph_export_response": {
"type": "object",
"required": ["items", "next_cursor", "has_more", "snapshot_as_of", "stats"],
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["seq", "edge_ref", "subject_ref", "predicate_ref", "predicate", "object_ref", "tombstoned"],
"properties": {
"seq": {"type": "integer"},
"edge_ref": {"type": "string"},
"subject_ref": {"type": "string"},
"predicate_ref": {"type": "string"},
"predicate": {"type": "string"},
"object_ref": {"type": "string"},
"tombstoned": {"type": "boolean"},
"metadata_ref": {"type": ["string", "null"]}
}
}
},
"next_cursor": {"type": ["string", "null"]},
"has_more": {"type": "boolean"},
"snapshot_as_of": {"type": "string"},
"stats": {
"type": "object",
"properties": {
"scanned_edges": {"type": "integer"},
"exported_items": {"type": "integer"}
}
}
}
},
"graph_import_request": {
"type": "object",
"required": ["items"],
"properties": {
"mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subject_ref": {"type": "string"},
"subject": {"type": "string"},
"predicate_ref": {"type": "string"},
"predicate": {"type": "string"},
"object_ref": {"type": "string"},
"object": {"type": "string"},
"metadata_ref": {"type": "string"}
}
}
}
}
},
"graph_import_response": {
"type": "object",
"required": ["ok", "applied", "results"],
"properties": {
"ok": {"type": "boolean"},
"applied": {"type": "integer"},
"results": {
"type": "array",
"items": {
"type": "object",
"required": ["index", "status", "code", "error", "edge_ref"],
"properties": {
"index": {"type": "integer"},
"status": {"type": "string", "enum": ["applied", "error"]},
"code": {"type": "integer"},
"error": {"type": ["string", "null"]},
"edge_ref": {"type": ["string", "null"]}
}
}
}
}
},
"graph_schema_predicates_request": {
"type": "object",
"properties": {
"mode": {"type": "string", "enum": ["strict", "warn", "off"]},
"provenance_mode": {"type": "string", "enum": ["optional", "required"]},
"predicates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"predicate_ref": {"type": "string"},
"predicate": {"type": "string"},
"domain": {"type": "string"},
"range": {"type": "string"}
}
}
}
}
},
"graph_schema_predicates_response": {
"type": "object",
"required": ["mode", "provenance_mode", "predicates"],
"properties": {
"mode": {"type": "string", "enum": ["strict", "warn", "off"]},
"provenance_mode": {"type": "string", "enum": ["optional", "required"]},
"predicates": {
"type": "array",
"items": {
"type": "object",
"required": ["predicate_ref", "domain", "range"],
"properties": {
"predicate_ref": {"type": "string"},
"domain": {"type": ["string", "null"]},
"range": {"type": ["string", "null"]}
}
}
}
}
},
"graph_stats_response": {
"type": "object",
"required": ["edges_total", "aliases_total", "index", "tombstones"],
"properties": {
"edges_total": {"type": "integer"},
"aliases_total": {"type": "integer"},
"index": {
"type": "object",
"required": ["built_for_edges", "src_buckets", "dst_buckets", "predicate_buckets", "src_predicate_buckets", "dst_predicate_buckets", "healthy"],
"properties": {
"built_for_edges": {"type": "integer"},
"src_buckets": {"type": "integer"},
"dst_buckets": {"type": "integer"},
"predicate_buckets": {"type": "integer"},
"src_predicate_buckets": {"type": "integer"},
"dst_predicate_buckets": {"type": "integer"},
"healthy": {"type": "boolean"}
}
},
"tombstones": {
"type": "object",
"required": ["edges", "ratio"],
"properties": {
"edges": {"type": "integer"},
"ratio": {"type": "number"}
}
}
}
},
"graph_capabilities_response": {
"type": "object",
"required": ["contract", "graph", "runtime"],
"properties": {
"contract": {"type": "string"},
"graph": {
"type": "object",
"required": ["version", "features", "limits", "modes"],
"properties": {
"version": {"type": "string"},
"features": {"type": "array", "items": {"type": "string"}},
"limits": {"type": "object"},
"modes": {"type": "object"}
}
},
"runtime": {"type": "object"}
}
},
"graph_changes_response": {
"type": "object",
"required": ["events", "next_cursor", "has_more"],
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"required": ["event", "cursor", "edge_ref", "subject_ref", "predicate_ref", "object_ref"],
"properties": {
"event": {"type": "string", "enum": ["edge_appended", "version_published", "tombstone_applied"]},
"cursor": {"type": "string"},
"edge_ref": {"type": "string"},
"subject_ref": {"type": "string"},
"predicate_ref": {"type": "string"},
"object_ref": {"type": "string"},
"concept_ref": {"type": "string"},
"ref": {"type": "string"},
"tombstoned_edge_ref": {"type": "string"}
}
}
},
"next_cursor": {"type": ["string", "null"]},
"has_more": {"type": "boolean"}
}
},
"graph_node_response": {
"type": "object",
"required": ["name", "concept_ref", "latest_ref", "versions", "outgoing", "incoming"],
"properties": {
"name": {"type": "string"},
"concept_ref": {"type": "string"},
"latest_ref": {"type": ["string", "null"]},
"versions": {"type": "array", "items": {"type": "string"}},
"outgoing": {
"type": "array",
"items": {
"type": "object",
"required": ["predicate_ref", "object_ref"],
"properties": {
"predicate_ref": {"type": "string"},
"object_ref": {"type": "string"},
"edge_ref": {"type": "string"},
"metadata_ref": {"type": ["string", "null"]}
}
}
},
"incoming": {
"type": "array",
"items": {
"type": "object",
"required": ["predicate_ref", "subject_ref"],
"properties": {
"predicate_ref": {"type": "string"},
"subject_ref": {"type": "string"},
"edge_ref": {"type": "string"},
"metadata_ref": {"type": ["string", "null"]}
}
}
}
}
},
"graph_history_response": {
"type": "object",
"required": ["name", "events"],
"properties": {
"name": {"type": "string"},
"events": {
"type": "array",
"items": {
"type": "object",
"required": ["event", "at_ms"],
"properties": {
"event": {"type": "string"},
"at_ms": {"type": "integer"},
"ref": {"type": ["string", "null"]},
"edge_ref": {"type": ["string", "null"]},
"details": {"type": "object"}
}
}
}
}
},
"pel_execute_request": {
"type": "object",
"required": ["program_ref", "inputs", "receipt"],
"properties": {
"program_ref": {"type": "string", "description": "hex ref or concept name"},
"scheme_ref": {"type": "string", "description": "hex ref or 'dag'"},
"params_ref": {"type": "string", "description": "hex ref or concept name"},
"inputs": {
"type": "object",
"properties": {
"refs": {
"type": "array",
"items": {"type": "string", "description": "hex ref or concept name"}
},
"inline_artifacts": {
"type": "array",
"items": {
"type": "object",
"required": ["body_hex"],
"properties": {
"content_type": {"type": "string"},
"type_tag": {"type": "string", "description": "hex tag id, optional"},
"body_hex": {"type": "string"}
}
}
}
}
},
"receipt": {
"type": "object",
"required": [
"input_manifest_ref",
"environment_ref",
"evaluator_id",
"executor_ref",
"started_at",
"completed_at"
],
"properties": {
"input_manifest_ref": {"type": "string", "description": "hex ref or concept name"},
"environment_ref": {"type": "string", "description": "hex ref or concept name"},
"evaluator_id": {"type": "string"},
"executor_ref": {"type": "string", "description": "hex ref or concept name"},
"sbom_ref": {"type": "string", "description": "hex ref or concept name"},
"parity_digest_hex": {"type": "string"},
"executor_fingerprint_ref": {"type": "string", "description": "hex ref or concept name"},
"run_id_hex": {"type": "string"},
"limits": {
"type": "object",
"required": ["cpu_ms", "wall_ms", "max_rss_kib", "io_reads", "io_writes"],
"properties": {
"cpu_ms": {"type": "integer"},
"wall_ms": {"type": "integer"},
"max_rss_kib": {"type": "integer"},
"io_reads": {"type": "integer"},
"io_writes": {"type": "integer"}
}
},
"logs": {
"type": "array",
"items": {
"type": "object",
"required": ["kind", "log_ref", "sha256_hex"],
"properties": {
"kind": {"type": "integer"},
"log_ref": {"type": "string", "description": "hex ref or concept name"},
"sha256_hex": {"type": "string"}
}
}
},
"determinism_level": {"type": "integer", "description": "0-255"},
"rng_seed_hex": {"type": "string"},
"signature_hex": {"type": "string"},
"started_at": {"type": "integer"},
"completed_at": {"type": "integer"}
}
},
"effects": {
"type": "object",
"properties": {
"publish_outputs": {"type": "boolean"},
"append_fed_log": {"type": "boolean"}
}
}
}
},
"pel_execute_response": {
"type": "object",
"required": ["run_ref", "receipt_ref", "stored_input_refs", "output_refs", "status"],
"properties": {
"run_ref": {"type": "string", "description": "hex ref"},
"trace_ref": {"type": "string", "description": "hex ref"},
"receipt_ref": {"type": "string", "description": "hex ref"},
"stored_input_refs": {"type": "array", "items": {"type": "string", "description": "hex ref"}},
"output_refs": {"type": "array", "items": {"type": "string", "description": "hex ref"}},
"status": {"type": "string"}
}
},
"error_response": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message", "retryable"],
"properties": {
"code": {"type": "string"},
"message": {"type": "string"},
"retryable": {"type": "boolean"}
}
}
}
}
}
}

View file

@ -0,0 +1,237 @@
# Amduat v2 App Developer Guide
This is the compact handoff guide for building an application against `amduatd` v2.
For machine-readable contracts, see `registry/amduatd-api-contract.v2.json`.
## 1) Runtime Model
- One daemon instance serves one ASL store root.
- Transport is local Unix socket HTTP.
- Auth is currently filesystem/socket permission based.
- All graph APIs are under `/v2/graph/*`.
Minimal local run:
```sh
./vendor/amduat/build/amduat-asl init --root .amduat-asl
./build/amduatd --root .amduat-asl --sock amduatd.sock --store-backend index
```
## 2) Request Conventions
- Use `X-Amduat-Space: <space_id>` for app isolation.
- Treat graph cursors as opaque tokens (`g1_*`), do not parse.
- Use `as_of` for snapshot-consistent reads.
- Use `include_tombstoned=true` only when you explicitly want retracted facts.
## 3) Core App Flows
### A. High-throughput ingest
Use `POST /v2/graph/batch` with:
- `idempotency_key` for deterministic retries
- `mode=continue_on_error` for partial-apply behavior
- per-item `metadata_ref` or `provenance` for trust/debug
Expect response:
- `ok` (overall)
- `applied` aggregate counts
- `results[]` with `{kind,index,status,code,error}`
### B. Multi-hop retrieval for agents
Primary endpoints:
- `GET /v2/graph/subgraph`
- `POST /v2/graph/retrieve`
- `POST /v2/graph/query` for declarative filtering
Use:
- `as_of` for stable reasoning snapshots
- `max_depth`, `max_fanout`, `limit_nodes`, `limit_edges`, `max_result_bytes`
- provenance filters where needed (`provenance_ref` / `provenance_min_confidence`)
### C. Incremental sync loop
Use `GET /v2/graph/changes`:
- Start with `since_cursor` (or bootstrap with `since_as_of`)
- Persist returned `next_cursor` after successful processing
- Handle `410` as replay-window expiry (full resync required)
- Optional long poll: `wait_ms`
### D. Fact correction
- Edge retraction: `POST /v2/graph/edges/tombstone`
- Node-version retraction: `POST /v2/graph/nodes/{name}/versions/tombstone`
Reads default to exclude tombstoned facts on retrieval surfaces unless `include_tombstoned=true`.
## 4) Endpoint Map (what to use when)
- Write node: `POST /v2/graph/nodes`
- Write version: `POST /v2/graph/nodes/{name}/versions`
- Write edge: `POST /v2/graph/edges`
- Batch write: `POST /v2/graph/batch`
- Point-ish read: `GET /v2/graph/nodes/{name}`
- Edge scan: `GET /v2/graph/edges`
- Neighbor scan: `GET /v2/graph/nodes/{name}/neighbors`
- Path lookup: `GET /v2/graph/paths`
- Subgraph: `GET /v2/graph/subgraph`
- Declarative query: `POST /v2/graph/query`
- Agent retrieval: `POST /v2/graph/retrieve`
- Changes feed: `GET /v2/graph/changes`
- Export: `POST /v2/graph/export`
- Import: `POST /v2/graph/import`
- Predicate policy: `GET/POST /v2/graph/schema/predicates`
- Health/readiness/metrics: `GET /v2/healthz`, `GET /v2/readyz`, `GET /v2/metrics`
- Graph runtime/capability: `GET /v2/graph/stats`, `GET /v2/graph/capabilities`
## 5) Provenance and Policy
Provenance object fields for writes:
- required: `source_uri`, `extractor`, `observed_at`, `ingested_at`, `trace_id`
- optional: `confidence`, `license`
Policy endpoint:
- `POST /v2/graph/schema/predicates`
Key modes:
- predicate validation: `strict|warn|off`
- provenance enforcement: `optional|required`
## 6) Error Handling and Retry Rules
- Retry-safe writes: only retries with same `idempotency_key` and identical payload.
- Validation failures: `400` or `422` (do not blind-retry).
- Not found for references/nodes: `404`.
- Cursor window expired: `410` on `/changes` (rebootstrap sync state).
- Result guard triggered: `422` (`max_result_bytes` or traversal/search limits).
- Internal errors: `500` (retry with backoff).
## 7) Performance and Safety Defaults
Recommended client defaults:
- Set explicit `limit` on scans.
- Always pass `max_result_bytes` on large retrieval requests.
- Keep `max_depth` conservative (start with 2-4).
- Enable `include_stats=true` in development to monitor scanned/returned counts and selected plan.
- Call `/v2/graph/capabilities` once at startup for feature/limit negotiation.
## 8) Minimal Startup Checklist (for external app)
1. Probe `GET /v2/readyz`.
2. Read `GET /v2/graph/capabilities`.
3. Configure schema/provenance policy (`POST /v2/graph/schema/predicates`) if your app owns policy.
4. Start ingest path (`/v2/graph/batch` idempotent).
5. Start change-consumer loop (`/v2/graph/changes`).
6. Serve retrieval via `/v2/graph/retrieve` and `/v2/graph/subgraph`.
7. Monitor `/v2/metrics` and `/v2/graph/stats`.
## 9) Useful Local Helpers
- `scripts/graph_client_helpers.sh` contains practical shell helpers for:
- idempotent batch ingest
- one-step changes sync
- subgraph retrieval
For integration tests/examples:
- `scripts/test_graph_queries.sh`
- `scripts/test_graph_contract.sh`
## 10) Copy/Paste Integration Skeleton
Set local defaults:
```sh
SOCK="amduatd.sock"
SPACE="app1"
BASE="http://localhost"
```
Startup probes:
```sh
curl --unix-socket "${SOCK}" -sS "${BASE}/v2/readyz"
curl --unix-socket "${SOCK}" -sS "${BASE}/v2/graph/capabilities"
```
Idempotent batch ingest:
```sh
curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/batch" \
-H "Content-Type: application/json" \
-H "X-Amduat-Space: ${SPACE}" \
-d '{
"idempotency_key":"app1-batch-0001",
"mode":"continue_on_error",
"nodes":[{"name":"doc:1"}],
"edges":[
{"subject":"doc:1","predicate":"ms.within_domain","object":"topic:alpha",
"provenance":{"source_uri":"urn:app:seed","extractor":"app-loader","observed_at":1,"ingested_at":2,"trace_id":"trace-1"}}
]
}'
```
Incremental changes loop (bash skeleton):
```sh
cursor=""
while true; do
if [ -n "${cursor}" ]; then
path="/v2/graph/changes?since_cursor=${cursor}&limit=200&wait_ms=15000"
else
path="/v2/graph/changes?limit=200&wait_ms=15000"
fi
resp="$(curl --unix-socket "${SOCK}" -sS "${BASE}${path}" -H "X-Amduat-Space: ${SPACE}")" || break
# TODO: parse and process resp.events[] in your app.
next="$(printf '%s\n' "${resp}" | sed -n 's/.*"next_cursor":"\([^"]*\)".*/\1/p')"
[ -n "${next}" ] && cursor="${next}"
done
```
Agent retrieval call:
```sh
curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/retrieve" \
-H "Content-Type: application/json" \
-H "X-Amduat-Space: ${SPACE}" \
-d '{
"roots":["doc:1"],
"goal_predicates":["ms.within_domain"],
"max_depth":2,
"max_fanout":1024,
"limit_nodes":200,
"limit_edges":400,
"max_result_bytes":1048576
}'
```
Subgraph snapshot read:
```sh
curl --unix-socket "${SOCK}" -sS \
"${BASE}/v2/graph/subgraph?roots[]=doc:1&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_stats=true&max_result_bytes=1048576" \
-H "X-Amduat-Space: ${SPACE}"
```
Edge correction (tombstone):
```sh
EDGE_REF="<edge_ref_to_retract>"
curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/edges/tombstone" \
-H "Content-Type: application/json" \
-H "X-Amduat-Space: ${SPACE}" \
-d "{\"edge_ref\":\"${EDGE_REF}\"}"
```

14
scripts/bootstrap_check.sh Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "${ROOT_DIR}/src/client.sh"
echo "== /v2/readyz =="
api_get "/v2/readyz"
echo
echo "== /v2/graph/capabilities =="
api_get "/v2/graph/capabilities"
echo

186
scripts/graph_client_helpers.sh Executable file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
# Reusable HTTP and graph client helpers for local unix-socket amduatd usage.
graph_helpers_init() {
if [[ $# -lt 1 ]]; then
echo "usage: graph_helpers_init ROOT_DIR" >&2
return 1
fi
GRAPH_HELPERS_ROOT_DIR="$1"
GRAPH_HELPERS_HTTP="${GRAPH_HELPERS_ROOT_DIR}/build/amduatd_http_unix"
GRAPH_HELPERS_USE_HTTP=0
if command -v curl >/dev/null 2>&1; then
if curl --help 2>/dev/null | grep -q -- '--unix-socket'; then
GRAPH_HELPERS_USE_HTTP=0
else
GRAPH_HELPERS_USE_HTTP=1
fi
else
GRAPH_HELPERS_USE_HTTP=1
fi
if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 && ! -x "${GRAPH_HELPERS_HTTP}" ]]; then
echo "missing http transport (need curl --unix-socket or build/amduatd_http_unix)" >&2
return 1
fi
}
graph_http_get() {
local sock="$1"
local path="$2"
shift 2
if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then
"${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method GET --path "${path}" "$@"
else
curl --silent --show-error --fail \
--unix-socket "${sock}" \
"$@" \
"http://localhost${path}"
fi
}
graph_http_get_allow() {
local sock="$1"
local path="$2"
shift 2
if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then
"${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method GET --path "${path}" --allow-status "$@"
else
curl --silent --show-error \
--unix-socket "${sock}" \
"$@" \
"http://localhost${path}"
fi
}
graph_http_post() {
local sock="$1"
local path="$2"
local data="$3"
shift 3
if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then
"${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method POST --path "${path}" --data "${data}" "$@"
else
curl --silent --show-error --fail \
--unix-socket "${sock}" \
"$@" \
--data-binary "${data}" \
"http://localhost${path}"
fi
}
graph_http_post_allow() {
local sock="$1"
local path="$2"
local data="$3"
shift 3
if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then
"${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method POST --path "${path}" --data "${data}" --allow-status "$@"
else
curl --silent --show-error \
--unix-socket "${sock}" \
"$@" \
--data-binary "${data}" \
"http://localhost${path}"
fi
}
graph_wait_for_ready() {
local sock="$1"
local pid="$2"
local log_path="$3"
local i
for i in $(seq 1 120); do
if ! kill -0 "${pid}" >/dev/null 2>&1; then
if [[ -f "${log_path}" ]] && grep -q "bind: Operation not permitted" "${log_path}"; then
echo "skip: bind not permitted for unix socket" >&2
return 77
fi
if [[ -f "${log_path}" ]]; then
cat "${log_path}" >&2
fi
return 1
fi
if [[ -S "${sock}" ]] && graph_http_get "${sock}" "/v1/meta" >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
graph_batch_ingest() {
local sock="$1"
local space="$2"
local payload="$3"
graph_http_post "${sock}" "/v2/graph/batch" "${payload}" \
--header "Content-Type: application/json" \
--header "X-Amduat-Space: ${space}"
}
graph_changes_sync_once() {
local sock="$1"
local space="$2"
local cursor="$3"
local limit="$4"
local path="/v2/graph/changes?limit=${limit}"
if [[ -n "${cursor}" ]]; then
path+="&since_cursor=${cursor}"
fi
graph_http_get "${sock}" "${path}" --header "X-Amduat-Space: ${space}"
}
graph_subgraph_fetch() {
local sock="$1"
local space="$2"
local root="$3"
local max_depth="$4"
local predicates="${5:-}"
local path="/v2/graph/subgraph?roots[]=${root}&max_depth=${max_depth}&dir=outgoing&limit_nodes=256&limit_edges=256"
if [[ -n "${predicates}" ]]; then
path+="&predicates[]=${predicates}"
fi
graph_http_get "${sock}" "${path}" --header "X-Amduat-Space: ${space}"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
if [[ $# -lt 1 ]]; then
echo "usage: $0 COMMAND ..." >&2
echo "commands: batch-ingest, sync-once, subgraph" >&2
exit 2
fi
cmd="$1"
shift
: "${AMDUATD_ROOT:?set AMDUATD_ROOT to repo root}"
graph_helpers_init "${AMDUATD_ROOT}"
case "${cmd}" in
batch-ingest)
if [[ $# -ne 3 ]]; then
echo "usage: $0 batch-ingest SOCK SPACE PAYLOAD_JSON" >&2
exit 2
fi
graph_batch_ingest "$1" "$2" "$3"
;;
sync-once)
if [[ $# -ne 4 ]]; then
echo "usage: $0 sync-once SOCK SPACE CURSOR LIMIT" >&2
exit 2
fi
graph_changes_sync_once "$1" "$2" "$3" "$4"
;;
subgraph)
if [[ $# -lt 4 || $# -gt 5 ]]; then
echo "usage: $0 subgraph SOCK SPACE ROOT MAX_DEPTH [PREDICATE]" >&2
exit 2
fi
graph_subgraph_fetch "$1" "$2" "$3" "$4" "${5:-}"
;;
*)
echo "unknown command: ${cmd}" >&2
exit 2
;;
esac
fi

29
scripts/ingest_example.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "${ROOT_DIR}/src/client.sh"
payload='{
"idempotency_key":"app1-seed-0001",
"mode":"continue_on_error",
"nodes":[{"name":"doc:1"},{"name":"topic:alpha"}],
"edges":[
{
"subject":"doc:1",
"predicate":"ms.within_domain",
"object":"topic:alpha",
"provenance":{
"source_uri":"urn:app:seed",
"extractor":"app-loader",
"observed_at":1,
"ingested_at":2,
"trace_id":"trace-seed-1"
}
}
]
}'
api_post_json "/v2/graph/batch" "${payload}"
echo

40
scripts/sync_loop.sh Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/config/env.local"
if [[ ! -f "${ENV_FILE}" ]]; then
ENV_FILE="${ROOT_DIR}/config/env.example"
fi
# shellcheck source=/dev/null
source "${ENV_FILE}"
# shellcheck source=/dev/null
source "${ROOT_DIR}/src/client.sh"
limit="${SYNC_LIMIT:-200}"
wait_ms="${SYNC_WAIT_MS:-15000}"
cursor_file="${ROOT_DIR}/.cursor"
cursor=""
if [[ -f "${cursor_file}" ]]; then
cursor="$(cat "${cursor_file}")"
fi
while true; do
if [[ -n "${cursor}" ]]; then
path="/v2/graph/changes?since_cursor=${cursor}&limit=${limit}&wait_ms=${wait_ms}"
else
path="/v2/graph/changes?limit=${limit}&wait_ms=${wait_ms}"
fi
resp="$(api_get "${path}")"
echo "${resp}"
next="$(printf '%s\n' "${resp}" | sed -n 's/.*"next_cursor":"\([^"]*\)".*/\1/p')"
if [[ -n "${next}" ]]; then
cursor="${next}"
printf '%s' "${cursor}" > "${cursor_file}"
fi
sleep 1
done

67
scripts/v2_app.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "${ROOT_DIR}/src/app_v2.sh"
usage() {
cat <<USAGE
usage: $0 COMMAND [args]
commands:
startup-check
ingest PAYLOAD_JSON
sync-once
sync-loop
retrieve ROOTS_CSV [GOAL_PREDICATES_CSV]
tombstone EDGE_REF
USAGE
}
if [[ $# -lt 1 ]]; then
usage >&2
exit 2
fi
app_init
cmd="$1"
shift
case "${cmd}" in
startup-check)
app_startup_checks
;;
ingest)
if [[ $# -ne 1 ]]; then
echo "usage: $0 ingest PAYLOAD_JSON" >&2
exit 2
fi
app_ingest_batch "$1"
;;
sync-once)
app_sync_once
;;
sync-loop)
app_sync_loop
;;
retrieve)
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 retrieve ROOTS_CSV [GOAL_PREDICATES_CSV]" >&2
exit 2
fi
app_retrieve_with_fallback "$1" "${2:-}"
;;
tombstone)
if [[ $# -ne 1 ]]; then
echo "usage: $0 tombstone EDGE_REF" >&2
exit 2
fi
app_tombstone_edge "$1"
;;
*)
usage >&2
exit 2
;;
esac

138
src/amduat_v2_client.sh Executable file
View file

@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=/dev/null
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh"
amduat_client_init() {
amduat_config_load
}
amduat_http_raw() {
local method="$1"
local path="$2"
local body="${3:-}"
local curl_args=(
--globoff
--silent
--show-error
--output -
--write-out $'\n%{http_code}'
--unix-socket "${SOCK}"
--connect-timeout "${CURL_CONNECT_TIMEOUT_SECONDS}"
--max-time "${CURL_MAX_TIME_SECONDS}"
-H "X-Amduat-Space: ${SPACE}"
-X "${method}"
"${BASE}${path}"
)
if [[ -n "${body}" ]]; then
curl_args=(
--globoff
--silent
--show-error
--output -
--write-out $'\n%{http_code}'
--unix-socket "${SOCK}"
--connect-timeout "${CURL_CONNECT_TIMEOUT_SECONDS}"
--max-time "${CURL_MAX_TIME_SECONDS}"
-H "Content-Type: application/json"
-H "X-Amduat-Space: ${SPACE}"
-X "${method}"
--data-binary "${body}"
"${BASE}${path}"
)
fi
curl "${curl_args[@]}"
}
_amduat_backoff_sleep() {
local delay_ms="$1"
local delay_secs
delay_secs="$(awk "BEGIN {printf \"%.3f\", ${delay_ms}/1000}")"
sleep "${delay_secs}"
}
_amduat_should_retry_status() {
local status="$1"
case "${status}" in
500) return 0 ;;
*) return 1 ;;
esac
}
_amduat_non_retryable_status() {
local status="$1"
case "${status}" in
400|404|410|422) return 0 ;;
*) return 1 ;;
esac
}
# Sets global AMDUAT_LAST_STATUS and AMDUAT_LAST_BODY.
amduat_api_call() {
local method="$1"
local path="$2"
local body="${3:-}"
local attempt=1
local delay_ms="${RETRY_INITIAL_MS}"
AMDUAT_LAST_STATUS="000"
AMDUAT_LAST_BODY=""
while (( attempt <= RETRY_MAX_ATTEMPTS )); do
local raw
local rc=0
raw="$(amduat_http_raw "${method}" "${path}" "${body}")" || rc=$?
if (( rc != 0 )); then
if (( attempt < RETRY_MAX_ATTEMPTS )); then
_amduat_backoff_sleep "${delay_ms}"
delay_ms=$(( delay_ms * 2 ))
if (( delay_ms > RETRY_MAX_MS )); then
delay_ms="${RETRY_MAX_MS}"
fi
attempt=$(( attempt + 1 ))
continue
fi
echo "request failed after retries: ${method} ${path}" >&2
return 1
fi
AMDUAT_LAST_STATUS="${raw##*$'\n'}"
AMDUAT_LAST_BODY="${raw%$'\n'*}"
if [[ "${AMDUAT_LAST_STATUS}" =~ ^2[0-9][0-9]$ ]]; then
return 0
fi
case "${AMDUAT_LAST_STATUS}" in
400) echo "400 bad request for ${method} ${path}" >&2 ;;
404) echo "404 not found for ${method} ${path}" >&2 ;;
410) echo "410 cursor window expired for ${method} ${path}" >&2 ;;
422) echo "422 unprocessable request/result guard for ${method} ${path}" >&2 ;;
500) echo "500 internal error for ${method} ${path}" >&2 ;;
*) echo "unexpected status ${AMDUAT_LAST_STATUS} for ${method} ${path}" >&2 ;;
esac
if _amduat_non_retryable_status "${AMDUAT_LAST_STATUS}"; then
return 1
fi
if _amduat_should_retry_status "${AMDUAT_LAST_STATUS}" && (( attempt < RETRY_MAX_ATTEMPTS )); then
_amduat_backoff_sleep "${delay_ms}"
delay_ms=$(( delay_ms * 2 ))
if (( delay_ms > RETRY_MAX_MS )); then
delay_ms="${RETRY_MAX_MS}"
fi
attempt=$(( attempt + 1 ))
continue
fi
return 1
done
return 1
}

103
src/app_v2.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=/dev/null
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/amduat_v2_client.sh"
app_init() {
amduat_client_init
}
app_startup_checks() {
amduat_api_call GET "/v2/readyz" || return 1
printf '%s\n' "${AMDUAT_LAST_BODY}"
amduat_api_call GET "/v2/graph/capabilities" || return 1
printf '%s\n' "${AMDUAT_LAST_BODY}"
}
app_ingest_batch() {
local payload="$1"
amduat_api_call POST "/v2/graph/batch" "${payload}"
printf '%s\n' "${AMDUAT_LAST_BODY}"
}
app_sync_once() {
local path="/v2/graph/changes?limit=${SYNC_LIMIT}&wait_ms=${SYNC_WAIT_MS}"
local cursor=""
if [[ -f "${CURSOR_FILE}" ]]; then
cursor="$(cat "${CURSOR_FILE}")"
fi
if [[ -n "${cursor}" ]]; then
path+="&since_cursor=${cursor}"
fi
if ! amduat_api_call GET "${path}"; then
if [[ "${AMDUAT_LAST_STATUS}" == "410" ]]; then
rm -f "${CURSOR_FILE}"
echo "changes cursor expired (410); cleared ${CURSOR_FILE}" >&2
return 0
fi
return 1
fi
printf '%s\n' "${AMDUAT_LAST_BODY}"
local next
next="$(printf '%s\n' "${AMDUAT_LAST_BODY}" | sed -n 's/.*"next_cursor"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
if [[ -n "${next}" ]]; then
printf '%s' "${next}" > "${CURSOR_FILE}"
fi
}
app_sync_loop() {
while true; do
app_sync_once
sleep 1
done
}
app_retrieve_with_fallback() {
local roots_csv="$1"
local goals_csv="${2:-}"
local roots_json
roots_json="$(printf '%s' "${roots_csv}" | awk -F',' 'BEGIN{printf "["} {for(i=1;i<=NF;i++){gsub(/^ +| +$/, "", $i); if (length($i)>0){if (printed) printf ","; printf "\"%s\"", $i; printed=1}}} END{printf "]"}')"
local goals_json="[]"
if [[ -n "${goals_csv}" ]]; then
goals_json="$(printf '%s' "${goals_csv}" | awk -F',' 'BEGIN{printf "["} {for(i=1;i<=NF;i++){gsub(/^ +| +$/, "", $i); if (length($i)>0){if (printed) printf ","; printf "\"%s\"", $i; printed=1}}} END{printf "]"}')"
fi
local payload
payload="{\"roots\":${roots_json},\"goal_predicates\":${goals_json},\"max_depth\":2,\"max_fanout\":256,\"limit_nodes\":200,\"limit_edges\":400,\"max_result_bytes\":1048576}"
if amduat_api_call POST "/v2/graph/retrieve" "${payload}"; then
printf '%s\n' "${AMDUAT_LAST_BODY}"
return 0
fi
local first_root
first_root="$(printf '%s' "${roots_csv}" | awk -F',' '{gsub(/^ +| +$/, "", $1); printf "%s", $1}')"
local fallback_path="/v2/graph/subgraph?roots[]=${first_root}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&max_result_bytes=1048576"
if [[ -n "${goals_csv}" ]]; then
local first_goal
first_goal="$(printf '%s' "${goals_csv}" | awk -F',' '{gsub(/^ +| +$/, "", $1); printf "%s", $1}')"
if [[ -n "${first_goal}" ]]; then
fallback_path+="&predicates[]=${first_goal}"
fi
fi
amduat_api_call GET "${fallback_path}"
printf '%s\n' "${AMDUAT_LAST_BODY}"
}
app_tombstone_edge() {
local edge_ref="$1"
local payload
payload="{\"edge_ref\":\"${edge_ref}\"}"
amduat_api_call POST "/v2/graph/edges/tombstone" "${payload}"
printf '%s\n' "${AMDUAT_LAST_BODY}"
}

34
src/client.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ROOT_DIR}/config/env.local"
if [[ ! -f "${ENV_FILE}" ]]; then
ENV_FILE="${ROOT_DIR}/config/env.example"
fi
# shellcheck source=/dev/null
source "${ENV_FILE}"
SOCK="${SOCK:-../amduatd.sock}"
BASE="${BASE:-http://localhost}"
SPACE="${SPACE:-app1}"
api_get() {
local path="$1"
curl --silent --show-error --fail \
--unix-socket "${SOCK}" \
-H "X-Amduat-Space: ${SPACE}" \
"${BASE}${path}"
}
api_post_json() {
local path="$1"
local body="$2"
curl --silent --show-error --fail \
--unix-socket "${SOCK}" \
-H "Content-Type: application/json" \
-H "X-Amduat-Space: ${SPACE}" \
-X POST \
--data-binary "${body}" \
"${BASE}${path}"
}

55
src/config.sh Executable file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
amduat_config_load() {
local root_dir
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
local override_sock="${SOCK:-}"
local override_base="${BASE:-}"
local override_space="${SPACE:-}"
local override_sync_limit="${SYNC_LIMIT:-}"
local override_sync_wait_ms="${SYNC_WAIT_MS:-}"
local override_cursor_file="${CURSOR_FILE:-}"
local override_retry_max_attempts="${RETRY_MAX_ATTEMPTS:-}"
local override_retry_initial_ms="${RETRY_INITIAL_MS:-}"
local override_retry_max_ms="${RETRY_MAX_MS:-}"
local override_connect_timeout="${CURL_CONNECT_TIMEOUT_SECONDS:-}"
local override_max_time="${CURL_MAX_TIME_SECONDS:-}"
local env_file="${root_dir}/config/env.local"
if [[ ! -f "${env_file}" ]]; then
env_file="${root_dir}/config/env.example"
fi
# shellcheck source=/dev/null
source "${env_file}"
if [[ -n "${override_sock}" ]]; then SOCK="${override_sock}"; fi
if [[ -n "${override_base}" ]]; then BASE="${override_base}"; fi
if [[ -n "${override_space}" ]]; then SPACE="${override_space}"; fi
if [[ -n "${override_sync_limit}" ]]; then SYNC_LIMIT="${override_sync_limit}"; fi
if [[ -n "${override_sync_wait_ms}" ]]; then SYNC_WAIT_MS="${override_sync_wait_ms}"; fi
if [[ -n "${override_cursor_file}" ]]; then CURSOR_FILE="${override_cursor_file}"; fi
if [[ -n "${override_retry_max_attempts}" ]]; then RETRY_MAX_ATTEMPTS="${override_retry_max_attempts}"; fi
if [[ -n "${override_retry_initial_ms}" ]]; then RETRY_INITIAL_MS="${override_retry_initial_ms}"; fi
if [[ -n "${override_retry_max_ms}" ]]; then RETRY_MAX_MS="${override_retry_max_ms}"; fi
if [[ -n "${override_connect_timeout}" ]]; then CURL_CONNECT_TIMEOUT_SECONDS="${override_connect_timeout}"; fi
if [[ -n "${override_max_time}" ]]; then CURL_MAX_TIME_SECONDS="${override_max_time}"; fi
SOCK="${SOCK:-../amduatd.sock}"
BASE="${BASE:-http://localhost}"
SPACE="${SPACE:-app1}"
SYNC_LIMIT="${SYNC_LIMIT:-200}"
SYNC_WAIT_MS="${SYNC_WAIT_MS:-15000}"
CURSOR_FILE="${CURSOR_FILE:-${root_dir}/.cursor}"
if [[ "${CURSOR_FILE}" != /* ]]; then
CURSOR_FILE="${root_dir}/${CURSOR_FILE}"
fi
RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-4}"
RETRY_INITIAL_MS="${RETRY_INITIAL_MS:-200}"
RETRY_MAX_MS="${RETRY_MAX_MS:-2000}"
CURL_CONNECT_TIMEOUT_SECONDS="${CURL_CONNECT_TIMEOUT_SECONDS:-2}"
CURL_MAX_TIME_SECONDS="${CURL_MAX_TIME_SECONDS:-30}"
}

100
tests/integration_v2.sh Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=/dev/null
source "${ROOT_DIR}/src/app_v2.sh"
require_jq() {
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required for integration_v2.sh" >&2
exit 2
fi
}
assert_contains() {
local haystack="$1"
local needle="$2"
if [[ "${haystack}" != *"${needle}"* ]]; then
echo "assertion failed: expected to find ${needle}" >&2
exit 1
fi
}
app_init
require_jq
if [[ ! -S "${SOCK}" ]]; then
echo "integration_v2.sh: SKIP (socket not found at ${SOCK})"
exit 77
fi
# 1) startup checks
startup_out="$(app_startup_checks)"
assert_contains "${startup_out}" '"ok"'
# 2) idempotent ingest (batch + continue_on_error)
run_id="$(date +%s)"
trace_id="trace-it-${run_id}"
idempotency_key="it-seed-${run_id}"
doc_name="doc:it${run_id}"
topic_name="topic:italpha${run_id}"
payload="$(cat <<JSON
{
"idempotency_key":"${idempotency_key}",
"mode":"continue_on_error",
"nodes":[{"name":"${doc_name}"},{"name":"${topic_name}"}],
"edges":[
{
"subject":"${doc_name}",
"predicate":"ms.within_domain",
"object":"${topic_name}",
"provenance":{
"source_uri":"urn:test:seed",
"extractor":"integration-test",
"observed_at":1,
"ingested_at":2,
"trace_id":"${trace_id}"
}
}
]
}
JSON
)"
ingest_out="$(app_ingest_batch "${payload}")"
assert_contains "${ingest_out}" '"ok":true'
# Re-submit same idempotency key + identical payload.
ingest_out_2="$(app_ingest_batch "${payload}")"
assert_contains "${ingest_out_2}" '"idempotency_key"'
# 3) incremental sync with durable opaque cursor
rm -f "${CURSOR_FILE}"
sync_out="$(app_sync_once)"
assert_contains "${sync_out}" '"events"'
[[ -s "${CURSOR_FILE}" ]] || { echo "cursor file not persisted" >&2; exit 1; }
# 4) retrieval endpoint + fallback path available
retrieve_out="$(app_retrieve_with_fallback "${doc_name}" "ms.within_domain")"
assert_contains "${retrieve_out}" '"edges"'
# Capture edge_ref using subgraph surface to avoid format differences.
subgraph_out="$(amduat_api_call GET "/v2/graph/subgraph?roots[]=${doc_name}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_stats=true&max_result_bytes=1048576" && printf '%s' "${AMDUAT_LAST_BODY}")"
edge_ref="$(printf '%s' "${subgraph_out}" | jq -r '.edges[0].edge_ref // empty')"
if [[ -z "${edge_ref}" ]]; then
echo "failed to resolve edge_ref" >&2
exit 1
fi
# 5) correction path and tombstone visibility semantics
app_tombstone_edge "${edge_ref}" >/dev/null
post_tombstone_retrieve="$(app_retrieve_with_fallback "${doc_name}" "ms.within_domain")"
post_edges_count="$(printf '%s' "${post_tombstone_retrieve}" | jq '.edges | length')"
if [[ "${post_edges_count}" != "0" ]]; then
echo "expected retrieval default to hide tombstoned edges" >&2
exit 1
fi
visible_tombstone="$(amduat_api_call GET "/v2/graph/subgraph?roots[]=${doc_name}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_tombstoned=true&max_result_bytes=1048576" && printf '%s' "${AMDUAT_LAST_BODY}")"
assert_contains "${visible_tombstone}" '"edges"'
echo "integration_v2.sh: PASS"