#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMPDIR="${TMPDIR:-/tmp}" mkdir -p "${TMPDIR}" if ! command -v grep >/dev/null 2>&1; then echo "skip: grep not found" >&2 exit 77 fi if ! command -v awk >/dev/null 2>&1; then echo "skip: awk not found" >&2 exit 77 fi # shellcheck source=/dev/null source "${ROOT_DIR}/scripts/graph_client_helpers.sh" graph_helpers_init "${ROOT_DIR}" AMDUATD_BIN="${ROOT_DIR}/build/amduatd" ASL_BIN="${ROOT_DIR}/build/vendor/amduat/amduat-asl" if [[ ! -x "${ASL_BIN}" ]]; then ASL_BIN="${ROOT_DIR}/vendor/amduat/build/amduat-asl" fi if [[ ! -x "${AMDUATD_BIN}" || ! -x "${ASL_BIN}" ]]; then echo "missing binaries; build amduatd and amduat-asl first" >&2 exit 1 fi tmp_root="$(mktemp -d -p "${TMPDIR}" amduatd-graph-contract-XXXXXX)" root="${tmp_root}/root" sock="${tmp_root}/amduatd.sock" space_id="graphcontract" log_file="${tmp_root}/amduatd.log" cleanup() { if [[ -n "${pid:-}" ]]; then kill "${pid}" >/dev/null 2>&1 || true wait "${pid}" >/dev/null 2>&1 || true fi rm -rf "${tmp_root}" } trap cleanup EXIT extract_json_string() { local key="$1" awk -v k="\"${key}\":\"" ' { pos = index($0, k); if (pos > 0) { rest = substr($0, pos + length(k)); end = index(rest, "\""); if (end > 0) { print substr(rest, 1, end - 1); exit 0; } } } ' } cursor_to_num() { local token="$1" if [[ -z "${token}" ]]; then echo 0 return 0 fi if [[ "${token}" == g1_* ]]; then echo "${token#g1_}" return 0 fi echo "${token}" } cursor_plus_one() { local token="$1" local n if [[ -z "${token}" ]]; then return 1 fi if [[ "${token}" == g1_* ]]; then n="${token#g1_}" printf 'g1_%s' "$((n + 1))" return 0 fi printf '%s' "$((token + 1))" } create_artifact() { local payload="$1" local resp local ref resp="$({ graph_http_post "${sock}" "/v1/artifacts" "${payload}" \ --header "Content-Type: application/octet-stream" \ --header "X-Amduat-Space: ${space_id}" })" ref="$(printf '%s\n' "${resp}" | extract_json_string "ref")" if [[ -z "${ref}" ]]; then echo "failed to parse artifact ref: ${resp}" >&2 exit 1 fi printf '%s' "${ref}" } create_node() { local name="$1" local ref="$2" graph_http_post "${sock}" "/v2/graph/nodes" "{\"name\":\"${name}\",\"ref\":\"${ref}\"}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null } create_edge() { local s="$1" local p="$2" local o="$3" graph_http_post "${sock}" "/v2/graph/edges" "{\"subject\":\"${s}\",\"predicate\":\"${p}\",\"object\":\"${o}\"}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null } mkdir -p "${root}" "${ASL_BIN}" init --root "${root}" >/dev/null "${AMDUATD_BIN}" --root "${root}" --sock "${sock}" --store-backend index --space "${space_id}" \ >"${log_file}" 2>&1 & pid=$! ready_rc=0 graph_wait_for_ready "${sock}" "${pid}" "${log_file}" || ready_rc=$? if [[ ${ready_rc} -eq 77 ]]; then exit 77 fi if [[ ${ready_rc} -ne 0 ]]; then echo "daemon not ready" >&2 exit 1 fi ref_a="$(create_artifact "contract-a")" ref_b="$(create_artifact "contract-b")" ref_c="$(create_artifact "contract-c")" ref_v1="$(create_artifact "contract-v1")" ref_v2="$(create_artifact "contract-v2")" create_node "gc-a" "${ref_a}" create_node "gc-b" "${ref_b}" create_node "gc-c" "${ref_c}" create_node "gc-v" "${ref_v1}" create_edge "gc-a" "ms.computed_by" "gc-b" create_edge "gc-b" "ms.computed_by" "gc-c" graph_http_post "${sock}" "/v2/graph/nodes/gc-v/versions" "{\"ref\":\"${ref_v2}\"}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null versions_cutoff_raw="$( graph_http_get "${sock}" "/v2/graph/changes?event_types[]=version_published&limit=100" \ --header "X-Amduat-Space: ${space_id}" )" versions_cutoff="$(printf '%s\n' "${versions_cutoff_raw}" | extract_json_string "next_cursor")" if [[ -z "${versions_cutoff}" ]]; then echo "missing version cutoff cursor: ${versions_cutoff_raw}" >&2 exit 1 fi versions_cutoff="$(cursor_plus_one "${versions_cutoff}")" graph_http_post "${sock}" "/v2/graph/nodes/gc-v/versions/tombstone" "{\"ref\":\"${ref_v2}\"}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null node_default="$( graph_http_get "${sock}" "/v2/graph/nodes/gc-v" \ --header "X-Amduat-Space: ${space_id}" )" node_default_latest="$(printf '%s\n' "${node_default}" | extract_json_string "latest_ref")" if [[ "${node_default_latest}" != "${ref_v1}" ]]; then echo "node default latest_ref mismatch (want ${ref_v1}): ${node_default}" >&2 exit 1 fi if echo "${node_default}" | grep -q "\"ref\":\"${ref_v2}\""; then echo "node default should hide tombstoned ${ref_v2}: ${node_default}" >&2 exit 1 fi node_all="$( graph_http_get "${sock}" "/v2/graph/nodes/gc-v?include_tombstoned=true" \ --header "X-Amduat-Space: ${space_id}" )" node_all_latest="$(printf '%s\n' "${node_all}" | extract_json_string "latest_ref")" if [[ "${node_all_latest}" != "${ref_v2}" ]]; then echo "node include_tombstoned latest_ref mismatch (want ${ref_v2}): ${node_all}" >&2 exit 1 fi echo "${node_all}" | grep -q "\"ref\":\"${ref_v2}\"" || { echo "node include_tombstoned should include ${ref_v2}: ${node_all}" >&2 exit 1 } node_asof="$( graph_http_get "${sock}" "/v2/graph/nodes/gc-v?as_of=${versions_cutoff}" \ --header "X-Amduat-Space: ${space_id}" )" node_asof_latest="$(printf '%s\n' "${node_asof}" | extract_json_string "latest_ref")" if [[ "${node_asof_latest}" != "${ref_v2}" ]]; then echo "node as_of before tombstone latest_ref mismatch (want ${ref_v2}): ${node_asof}" >&2 exit 1 fi echo "${node_asof}" | grep -q "\"ref\":\"${ref_v2}\"" || { echo "node as_of before tombstone should include ${ref_v2}: ${node_asof}" >&2 exit 1 } history_default="$( graph_http_get "${sock}" "/v2/graph/history/gc-v" \ --header "X-Amduat-Space: ${space_id}" )" history_default_latest="$(printf '%s\n' "${history_default}" | extract_json_string "latest_ref")" if [[ "${history_default_latest}" != "${ref_v1}" ]]; then echo "history default latest_ref mismatch (want ${ref_v1}): ${history_default}" >&2 exit 1 fi if echo "${history_default}" | grep -q "\"ref\":\"${ref_v2}\""; then echo "history default should hide tombstoned ${ref_v2}: ${history_default}" >&2 exit 1 fi history_all="$( graph_http_get "${sock}" "/v2/graph/history/gc-v?include_tombstoned=true" \ --header "X-Amduat-Space: ${space_id}" )" history_all_latest="$(printf '%s\n' "${history_all}" | extract_json_string "latest_ref")" if [[ "${history_all_latest}" != "${ref_v2}" ]]; then echo "history include_tombstoned latest_ref mismatch (want ${ref_v2}): ${history_all}" >&2 exit 1 fi echo "${history_all}" | grep -q "\"ref\":\"${ref_v2}\"" || { echo "history include_tombstoned should include ${ref_v2}: ${history_all}" >&2 exit 1 } # schema strict: block predicate not in allowed list. strict_policy='{"mode":"strict","predicates":[{"predicate":"ms.computed_by"}]}' graph_http_post "${sock}" "/v2/graph/schema/predicates" "${strict_policy}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null strict_reject="$({ graph_http_post_allow "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.within_domain","object":"gc-c"}' \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${strict_reject}" | grep -q 'predicate rejected by schema policy' || { echo "strict mode should reject disallowed predicate: ${strict_reject}" >&2 exit 1 } # schema warn: same write should pass. warn_policy='{"mode":"warn","predicates":[{"predicate":"ms.computed_by"}]}' graph_http_post "${sock}" "/v2/graph/schema/predicates" "${warn_policy}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null warn_accept="$({ graph_http_post "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.within_domain","object":"gc-c"}' \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${warn_accept}" | grep -q '"edge_ref":"' || { echo "warn mode should allow disallowed predicate: ${warn_accept}" >&2 exit 1 } # provenance required: writes without metadata/provenance must fail with 422. required_policy='{"mode":"warn","provenance_mode":"required","predicates":[{"predicate":"ms.computed_by"}]}' graph_http_post "${sock}" "/v2/graph/schema/predicates" "${required_policy}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null schema_required="$( graph_http_get "${sock}" "/v2/graph/schema/predicates" \ --header "X-Amduat-Space: ${space_id}" )" echo "${schema_required}" | grep -q '"provenance_mode":"required"' || { echo "schema provenance_mode did not persist: ${schema_required}" >&2 exit 1 } required_reject="$({ graph_http_post_allow "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b"}' \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${required_reject}" | grep -q 'provenance required by schema policy' || { echo "required provenance should reject missing attachment: ${required_reject}" >&2 exit 1 } required_version_reject="$({ graph_http_post_allow "${sock}" "/v2/graph/nodes/gc-a/versions" "{\"ref\":\"${ref_v1}\"}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${required_version_reject}" | grep -q 'provenance required by schema policy' || { echo "required provenance should reject version write without attachment: ${required_version_reject}" >&2 exit 1 } required_accept="$({ graph_http_post "${sock}" "/v2/graph/edges" '{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b","provenance":{"source_uri":"urn:test","extractor":"contract-test","observed_at":1,"ingested_at":2,"trace_id":"trace-required-1"}}' \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${required_accept}" | grep -q '"edge_ref":"' || { echo "required provenance should allow explicit provenance payload: ${required_accept}" >&2 exit 1 } # reset to optional so remaining tests can use minimal payloads. optional_policy='{"mode":"warn","provenance_mode":"optional","predicates":[{"predicate":"ms.computed_by"}]}' graph_http_post "${sock}" "/v2/graph/schema/predicates" "${optional_policy}" \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" >/dev/null # batch idempotency replay must be deterministic. idem_payload='{"idempotency_key":"gc-idem-1","mode":"continue_on_error","edges":[{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-c"},{"subject":"gc-missing","predicate":"ms.computed_by","object":"gc-c"}]}' idem_first="$(graph_batch_ingest "${sock}" "${space_id}" "${idem_payload}")" idem_second="$(graph_batch_ingest "${sock}" "${space_id}" "${idem_payload}")" if [[ "${idem_first}" != "${idem_second}" ]]; then echo "idempotent replay mismatch" >&2 echo "first=${idem_first}" >&2 echo "second=${idem_second}" >&2 exit 1 fi # payload mismatch on same idempotency key must fail. idem_conflict="$({ graph_http_post_allow "${sock}" "/v2/graph/batch" '{"idempotency_key":"gc-idem-1","mode":"continue_on_error","edges":[{"subject":"gc-a","predicate":"ms.computed_by","object":"gc-b"}]}' \ --header "Content-Type: application/json" \ --header "X-Amduat-Space: ${space_id}" })" echo "${idem_conflict}" | grep -q 'idempotency_key reuse with different payload' || { echo "idempotency conflict missing expected error: ${idem_conflict}" >&2 exit 1 } # changes sync helper: cursor monotonic + resumable loop. changes_1="$(graph_changes_sync_once "${sock}" "${space_id}" "" 2)" cursor_1="$(printf '%s\n' "${changes_1}" | extract_json_string "next_cursor")" if [[ -z "${cursor_1}" ]]; then echo "changes first page missing next_cursor: ${changes_1}" >&2 exit 1 fi changes_2="$(graph_changes_sync_once "${sock}" "${space_id}" "${cursor_1}" 2)" cursor_2="$(printf '%s\n' "${changes_2}" | extract_json_string "next_cursor")" if [[ -z "${cursor_2}" ]]; then echo "changes second page missing next_cursor: ${changes_2}" >&2 exit 1 fi num_1="$(cursor_to_num "${cursor_1}")" num_2="$(cursor_to_num "${cursor_2}")" if [[ "${num_2}" -lt "${num_1}" ]]; then echo "changes cursor regressed: ${cursor_1} -> ${cursor_2}" >&2 exit 1 fi # subgraph helper should return connected nodes. subgraph_resp="$(graph_subgraph_fetch "${sock}" "${space_id}" "gc-a" 2 "ms.computed_by")" echo "${subgraph_resp}" | grep -q '"name":"gc-a"' || { echo "subgraph missing gc-a: ${subgraph_resp}" >&2 exit 1 } echo "${subgraph_resp}" | grep -q '"name":"gc-b"' || { echo "subgraph missing gc-b: ${subgraph_resp}" >&2 exit 1 } echo "ok: v2 graph contract tests passed"