2026-04-10 20:49:41 +03:00
|
|
|
|
use std::env;
|
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-05-08 16:57:01 +02:00
|
|
|
|
use std::sync::Arc;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
use axum::Router;
|
|
|
|
|
|
use axum::body::{Body, to_bytes};
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
use axum::http::header::AUTHORIZATION;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
use axum::http::{Method, Request, StatusCode};
|
chore(lance): bump 4.0.0 → 6.0.1 (DataFusion 52→53, Arrow 57→58) (#111)
* tests: add lance_surface_guards pre-flight pins for the v6 bump
Land 8 named guards in a new test file that pin Lance API surfaces
OmniGraph relies on. Each guard turns a silent-break risk (variant
rename, struct restructure, async-flip) into a red CI bar instead of
runtime drift.
Guards (mapped to the silent-break inventory from the v6 migration plan):
Runtime (#[tokio::test]):
1. lance_error_too_much_write_contention_variant_exists — pins the
variant referenced by db/manifest/publisher.rs::map_lance_publish_error.
2. manifest_location_field_shape — pins .path/.size/.e_tag/.naming_scheme
types and ManifestLocation accessor returning &Self (the access
pattern at db/manifest/metadata.rs:84-88).
6. write_params_default_does_not_set_storage_version — confirms our
explicit V2_2 pin remains load-bearing (blob v2 requirement).
Compile-only async fns (#[allow(...)] + unimplemented!() placeholders;
never run, but cargo build --tests enforces the API shape):
3. checkout_version + restore chain — pins the recovery rollback hammer
at db/manifest/recovery.rs:505-522.
4. DatasetBuilder::from_namespace().with_branch().with_version().load()
— pins the namespace builder chain at db/manifest/namespace.rs:162-174.
5. MergeInsertBuilder fluent chain — pins the manifest CAS at
db/manifest/publisher.rs:370-391, including the return shape
(Arc<Dataset>, MergeStats).
7. compact_files(&mut ds, CompactionOptions, None) — pins
db/omnigraph/optimize.rs:107.
8. DeleteResult { new_dataset, num_deleted_rows } — pins the inline
delete result shape (MR-A will repurpose this guard to the staged
two-phase variant once Lance #6658 migration lands).
This is commit 1 of the chore/lance-6.0.1 migration. Cargo bump
follows in commit 2 (will trigger the guards under v6 if any surface
drifted).
Per the migration plan at ~/.claude/plans/shimmering-percolating-duckling.md
(written this session). Two guards from the plan deferred to follow-up:
- manifest_cas_returns_row_level_contention_variant (full publisher
race integration test — needs harness scaffolding)
- table_version_metadata_byte_compatible_with_v4 (TableVersionMetadata
is pub(crate); requires test reach extension).
Verified on v4: cargo test -p omnigraph-engine --test lance_surface_guards
passes 3/3 runtime tests; cargo build -p omnigraph-engine --tests
compiles all 5 compile-only guards clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(deps): bump Lance 4.0.0 → 6.0.1, DataFusion 52 → 53, Arrow 57 → 58
The Cargo bump itself. Source is intentionally untouched — this commit
will not compile. The compile errors are the work-list for subsequent
commits on this branch.
Lance updates: lance + 7 sub-crates 4.0.0 → 6.0.1. Transitive churn:
+ lance-tokenizer v6.0.1 (vendored tokenizer per Lance PR #6512)
+ object_store 0.13.x (Lance 6 brings it transitively; our explicit
pin stays at 0.12.5 for now — revisit in stages if diamond bites)
- tantivy* crates (replaced by lance-tokenizer)
Compile error landscape on this commit (11 errors):
• 1× E0432: `lance_index::DatasetIndexExt` import (Lance PR #6280
moved it to lance::index). Sites: table_store.rs:20,
db/manifest.rs:37 (the second site was missed by the pre-flight
inventory).
• 8× E0599: `create_index_builder` / `load_indices` missing on
`lance::Dataset` — all downstream of the DatasetIndexExt move.
Once the import is corrected on table_store.rs and db/manifest.rs,
these resolve automatically.
• 2× E0063: missing field `is_only_declared` in `DescribeTableResponse`
initializer at db/manifest/namespace.rs:221, 364. New Lance
namespace field per the v5 namespace restructure (PR #6186).
Surface guards (lance_surface_guards.rs, commit d571fa8) all still
compile + the 3 runtime ones pass on v6 — none of the silent-break
surfaces drifted. That's the load-bearing observation: the publisher
CAS chain, ManifestLocation field shape, checkout_version/restore,
DatasetBuilder fluent chain, MergeInsertBuilder return shape,
WriteParams::default, compact_files signature, and DeleteResult
fields are all v6-stable.
Next commits address the 11 errors per the migration plan stages
3-8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* imports: move DatasetIndexExt to lance::index (Lance PR #6280)
Lance 5.0 (PR #6280) moved `DatasetIndexExt` out of `lance-index` into
`lance::index`. `is_system_index` and `IndexType` stayed in `lance-index`.
Mechanical update of 6 import sites:
crates/omnigraph/src/table_store.rs:20 — split into two `use` lines
crates/omnigraph-server/tests/server.rs:10 — was traits::DatasetIndexExt
crates/omnigraph/tests/search.rs:6
crates/omnigraph/tests/branching.rs:7
crates/omnigraph/tests/failpoints.rs:467
crates/omnigraph-cli/tests/cli.rs:3 — was traits::DatasetIndexExt
All 9 E0599 cascading errors on .create_index_builder / .load_indices
resolve once the trait is back in scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* namespace: add is_only_declared field to DescribeTableResponse
Lance namespace 6.0.0 added `is_only_declared: Option<bool>` to
`DescribeTableResponse` (lance-namespace-reqwest-client 0.7+ via the
v5.0 namespace API restructure, Lance PR #6186). Set to `Some(false)`
because every table BranchManifestNamespace returns from describe_table
is materialized — the manifest snapshot only includes entries for
tables we've already opened via Dataset::open.
Two sites in db/manifest/namespace.rs (BranchManifestNamespace +
StagedTableNamespace impls of LanceNamespace::describe_table).
Closes the last two compile errors from the v6 bump in the engine lib.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* cargo: add lance to omnigraph-cli + omnigraph-server dev-deps
Stage 3 moved DatasetIndexExt imports from `lance-index` to `lance::index`
in the cli and server test crates. Both crates only had `lance-index`
in their dev-dependencies; add `lance` alongside so the new path
resolves.
This is the last compile-error fix from the v6 bump — `cargo build
--workspace --tests` is now green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: refresh Lance alignment audit for v6.0.1; bump surveyed version
Per CLAUDE.md maintenance rule 2 (same-PR docs):
- docs/dev/lance.md: replace the v4.0.1 alignment audit stanza with
the v6.0.1 audit. Captures every v5/v6 finding from this PR (the
DatasetIndexExt move, DescribeTableResponse.is_only_declared,
MergeInsertBuilder return shape, ManifestLocation field shape,
LanceFileVersion::default flip, file-reader async, tokenizer
vendor, Lance #6658/#6666/#6877 status). Cross-references each
guard in tests/lance_surface_guards.rs.
- AGENTS.md: bump "Storage substrate: Lance 4.x" → "Lance 6.x".
Note: surveyed crate version stays at 0.4.2 — substrate version
bumps are independent of OmniGraph's release version.
- crates/omnigraph/src/storage_layer.rs: update the trait module-level
doc-comment to reflect that Lance #6658 closed 2026-05-14 and
delete_where two-phase migration is MR-A (the next follow-up).
#6666 stays open; create_vector_index inline residual stays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* tests: silence clippy::diverging_sub_expression on compile-only guards
The five `_compile_*` async fns in lance_surface_guards.rs use
`let ds: Dataset = unimplemented!()` as a placeholder so type inference
can chase the method chain we want to pin, without ever running the
function. Clippy's `diverging_sub_expression` lint flags this pattern
because the RHS diverges; that's the entire point. Added to the
per-fn `#[allow(...)]` list, alongside dead_code / unreachable_code /
unused_variables / unused_mut already there.
No behavior change. cargo test -p omnigraph-engine --test
lance_surface_guards still 3/3 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: correct #6658 status — closed but API ships in Lance v7.x, not v6.0.1
The audit stanza in docs/dev/lance.md and the storage_layer.rs trait
doc-comment both implied the public DeleteBuilder::execute_uncommitted
API shipped with Lance 6.0.1. It did not. Issue #6658 closed
2026-05-14, but binary search across the release stream confirms:
v6.0.1 ❌ no pub async fn execute_uncommitted on DeleteBuilder
v6.1.0-rc.1 ❌
v7.0.0-beta.5 ❌
v7.0.0-beta.10 ✅ first appearance
v7.0.0-rc.1 ✅
So MR-A (delete two-phase migration) is gated on the Lance v7.x bump,
not on this PR. v7.0.0-rc.1 dropped 2026-05-21; GA likely within a
week.
No behavior change. Doc-only correction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(lib): bump recursion_limit to 256 — Lance 6 trait depth on Linux
Lance 6's heavier trait surface around futures/streams in storage_layer.rs's
staged-write API pushes the rustc trait-resolution recursion limit past
the default 128 on Linux builds. CI on PR #111 surfaced this in both
`Test Workspace` and `Test omnigraph-server --features aws`:
error: queries overflow the depth limit!
= help: consider increasing the recursion limit by adding a
`#![recursion_limit = "256"]` attribute to your crate (`omnigraph`)
= note: query depth increased by 130 when computing layout of
`{async block@crates/omnigraph/src/storage_layer.rs:697:5: 697:10}`
(The async block is `stage_create_btree_index`'s body — its return type
is several layers of `impl Future<Output=Result<StagedHandle>>` deep on
top of Lance's own builder return types.)
Local macOS builds happened to short-circuit before tripping the limit,
which is why this didn't surface during the v6 bump sequence. The fix
rustc itself suggests is one line at the crate root.
No behavior change. Revisit if a future Lance bump stops needing it.
Verified: `cargo build --locked -p omnigraph-server --features aws`
compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:42:29 +01:00
|
|
|
|
use lance::index::DatasetIndexExt;
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
use omnigraph::db::{Omnigraph, ReadTarget, SchemaApplyOptions};
|
|
|
|
|
|
use omnigraph::error::OmniError;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
use omnigraph::loader::{LoadMode, load_jsonl};
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
use omnigraph_policy::{PolicyChecker, PolicyEngine};
|
2026-04-10 20:49:41 +03:00
|
|
|
|
use omnigraph_server::api::{
|
|
|
|
|
|
BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest,
|
2026-04-18 00:30:46 +03:00
|
|
|
|
IngestRequest, ReadRequest, SchemaApplyRequest, SchemaOutput,
|
2026-04-10 20:49:41 +03:00
|
|
|
|
};
|
|
|
|
|
|
use omnigraph_server::{AppState, build_app};
|
|
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
|
|
use serial_test::serial;
|
|
|
|
|
|
use tower::ServiceExt;
|
|
|
|
|
|
|
|
|
|
|
|
const MUTATION_QUERIES: &str = r#"
|
|
|
|
|
|
query insert_person($name: String, $age: I32) {
|
|
|
|
|
|
insert Person { name: $name, age: $age }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
query set_age($name: String, $age: I32) {
|
|
|
|
|
|
update Person set { age: $age } where name = $name
|
|
|
|
|
|
}
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
const POLICY_YAML: &str = r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
team: [act-andrew, act-bruno, act-ragnor]
|
|
|
|
|
|
admins: [act-ragnor]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: team-read
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: team }
|
|
|
|
|
|
actions: [read]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
- id: admins-export
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: admins }
|
|
|
|
|
|
actions: [export]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
- id: team-write-unprotected
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: team }
|
|
|
|
|
|
actions: [change]
|
|
|
|
|
|
branch_scope: unprotected
|
|
|
|
|
|
- id: admins-merge
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: admins }
|
|
|
|
|
|
actions: [branch_delete, branch_merge]
|
|
|
|
|
|
target_branch_scope: protected
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
const POLICY_PROTECTED_READ_YAML: &str = r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
team: [act-bruno]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: protected-read
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: team }
|
|
|
|
|
|
actions: [read]
|
|
|
|
|
|
branch_scope: protected
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
const INGEST_CREATE_ONLY_POLICY_YAML: &str = r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
team: [act-bruno]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: team-branch-create
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: team }
|
|
|
|
|
|
actions: [branch_create]
|
|
|
|
|
|
target_branch_scope: unprotected
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
2026-04-12 04:01:14 +03:00
|
|
|
|
const SCHEMA_APPLY_POLICY_YAML: &str = r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
admins: [act-ragnor]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: admins-schema-apply
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: admins }
|
|
|
|
|
|
actions: [schema_apply]
|
|
|
|
|
|
target_branch_scope: protected
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
|
|
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
|
.join("../omnigraph/tests/fixtures")
|
|
|
|
|
|
.join(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn init_loaded_graph() -> tempfile::TempDir {
|
|
|
|
|
|
init_graph_with_schema_and_data(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&fs::read_to_string(fixture("test.jsonl")).unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn init_graph_with_schema_and_data(schema: &str, data: &str) -> tempfile::TempDir {
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
fs::create_dir_all(&graph).unwrap();
|
|
|
|
|
|
Omnigraph::init(graph.to_str().unwrap(), schema)
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
load_jsonl(&mut db, data, LoadMode::Overwrite)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
temp
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn init_graph_with_schema(schema: &str) -> tempfile::TempDir {
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
fs::create_dir_all(&graph).unwrap();
|
|
|
|
|
|
Omnigraph::init(graph.to_str().unwrap(), schema)
|
2026-04-12 04:01:14 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
temp
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
fn graph_path(root: &Path) -> PathBuf {
|
2026-04-10 20:49:41 +03:00
|
|
|
|
root.join("server.omni")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn drifted_test_schema() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("age: I32?", "age: I64?")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn manifest_dataset_version(graph: &Path) -> u64 {
|
|
|
|
|
|
Omnigraph::open(graph.to_string_lossy().as_ref())
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.version()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
fn s3_test_graph_uri(suite: &str) -> Option<String> {
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let bucket = env::var("OMNIGRAPH_S3_TEST_BUCKET").ok()?;
|
|
|
|
|
|
let prefix = env::var("OMNIGRAPH_S3_TEST_PREFIX")
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
|
.unwrap_or_else(|| "omnigraph-itests".to_string());
|
|
|
|
|
|
let unique = std::time::SystemTime::now()
|
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
|
.ok()?
|
|
|
|
|
|
.as_nanos();
|
|
|
|
|
|
Some(format!("s3://{}/{}/{}/{}", bucket, prefix, suite, unique))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_loaded_graph() -> (tempfile::TempDir, Router) {
|
|
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
/// Build a permit-all policy YAML that grants every action used by the
|
|
|
|
|
|
/// HTTP-layer tests to the listed actor names. MR-723 default-deny
|
|
|
|
|
|
/// closed the "tokens but no policy" loophole; helpers that used to
|
|
|
|
|
|
/// represent "auth without policy" now install this permit-all policy
|
|
|
|
|
|
/// so test cases retain their pre-MR-723 semantics ("auth required,
|
|
|
|
|
|
/// every action permitted") without conflicting with the new state
|
|
|
|
|
|
/// matrix. Tests that specifically need the State-2 deny path use
|
2026-05-24 16:46:00 +01:00
|
|
|
|
/// `app_for_graph_with_auth_tokens_only` instead.
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
fn permit_all_policy_yaml(actors: &[&str]) -> String {
|
|
|
|
|
|
let members = actors
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|a| format!("\"{a}\""))
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join(", ");
|
|
|
|
|
|
format!(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
permitted: [{members}]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: permit-data
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: {{ group: permitted }}
|
|
|
|
|
|
actions: [read, change, export]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
- id: permit-protected-target-actions
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: {{ group: permitted }}
|
|
|
|
|
|
actions: [schema_apply, branch_create, branch_delete, branch_merge]
|
|
|
|
|
|
target_branch_scope: any
|
|
|
|
|
|
"#
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_loaded_graph_with_auth(token: &str) -> (tempfile::TempDir, Router) {
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
// `AppState::new_with_bearer_token(token)` maps the token to actor "default";
|
|
|
|
|
|
// permit-all policy needs to include that actor.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, permit_all_policy_yaml(&["default"])).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
vec![("default".to_string(), token.to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_loaded_graph_with_auth_tokens(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
tokens: &[(&str, &str)],
|
|
|
|
|
|
) -> (tempfile::TempDir, Router) {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
let actors: Vec<&str> = tokens.iter().map(|(actor, _)| *actor).collect();
|
|
|
|
|
|
fs::write(&policy_path, permit_all_policy_yaml(&actors)).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
tokens
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
|
|
|
|
|
|
.collect(),
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_loaded_graph_with_auth_tokens_and_policy(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
tokens: &[(&str, &str)],
|
|
|
|
|
|
policy: &str,
|
|
|
|
|
|
) -> (tempfile::TempDir, Router) {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, policy).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
tokens
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
schema: &str,
|
|
|
|
|
|
tokens: &[(&str, &str)],
|
|
|
|
|
|
policy: &str,
|
|
|
|
|
|
) -> (tempfile::TempDir, Router) {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_graph_with_schema(schema).await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, policy).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-12 04:01:14 +03:00
|
|
|
|
tokens
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
/// MR-723 default-deny mode: bearer tokens configured, no policy file.
|
|
|
|
|
|
/// Exercises ServerRuntimeState::DefaultDeny — authenticated requests
|
|
|
|
|
|
/// for Read succeed, every other action is rejected with 403 from
|
|
|
|
|
|
/// `authorize_request`'s state-2 branch.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn app_for_graph_with_auth_tokens_only(
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
schema: &str,
|
|
|
|
|
|
tokens: &[(&str, &str)],
|
|
|
|
|
|
) -> (tempfile::TempDir, Router) {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_graph_with_schema(schema).await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
tokens
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(actor, token)| ((*actor).to_string(), (*token).to_string()))
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
(temp, build_app(state))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 04:01:14 +03:00
|
|
|
|
fn additive_schema_with_nickname() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
|
|
|
|
" age: I32?\n}",
|
|
|
|
|
|
" age: I32?\n nickname: String?\n}",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
fn schema_without_age() -> String {
|
|
|
|
|
|
// Drop the nullable `age` column from the test schema. Used by the
|
|
|
|
|
|
// HTTP soft/hard drop tests below.
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace(" age: I32?\n", "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn schema_without_company() -> String {
|
|
|
|
|
|
// Drop the `Company` node type and the edge referencing it. Used
|
|
|
|
|
|
// by the HTTP DropType test below. Hand-crafted (no template
|
|
|
|
|
|
// string replace) because the fixture interleaves the type and
|
|
|
|
|
|
// its edge.
|
|
|
|
|
|
r#"node Person {
|
|
|
|
|
|
name: String @key
|
|
|
|
|
|
age: I32?
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
edge Knows: Person -> Person {
|
|
|
|
|
|
since: Date?
|
|
|
|
|
|
}
|
|
|
|
|
|
"#
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 04:01:14 +03:00
|
|
|
|
fn renamed_person_schema() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("node Person {\n", "node Human @rename_from(\"Person\") {\n")
|
|
|
|
|
|
.replace("edge Knows: Person -> Person", "edge Knows: Human -> Human")
|
|
|
|
|
|
.replace(
|
|
|
|
|
|
"edge WorksAt: Person -> Company",
|
|
|
|
|
|
"edge WorksAt: Human -> Company",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn renamed_age_schema() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("age: I32?", "years: I32? @rename_from(\"age\")")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn indexed_name_schema() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("name: String @key", "name: String @key @index")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn unsupported_schema_change() -> String {
|
|
|
|
|
|
fs::read_to_string(fixture("test.pg"))
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("age: I32?", "age: I64?")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
async fn json_response(app: &Router, request: Request<Body>) -> (StatusCode, Value) {
|
|
|
|
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let value = serde_json::from_slice(&body).unwrap();
|
|
|
|
|
|
(status, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 04:01:14 +03:00
|
|
|
|
#[tokio::test]
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn schema_apply_route_updates_graph_for_authorized_admin() {
|
|
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let schema = additive_schema_with_nickname();
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: schema,
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
reopened.catalog().node_types["Person"]
|
|
|
|
|
|
.properties
|
|
|
|
|
|
.contains_key("nickname")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_requires_schema_apply_policy_permission() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: additive_schema_with_nickname(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
payload["code"],
|
|
|
|
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Forbidden).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_requires_bearer_token_when_policy_enabled() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: additive_schema_with_nickname(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
payload["code"],
|
|
|
|
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Unauthorized).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_can_rename_type() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: renamed_person_schema(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let snapshot = reopened
|
|
|
|
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert!(snapshot.entry("node:Human").is_some());
|
|
|
|
|
|
assert!(snapshot.entry("node:Person").is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_can_rename_property() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: renamed_age_schema(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let person = &reopened.catalog().node_types["Person"];
|
|
|
|
|
|
assert!(person.properties.contains_key("years"));
|
|
|
|
|
|
assert!(!person.properties.contains_key("age"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_can_add_index() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let before_index_count = {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
|
|
|
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
|
|
|
|
dataset.load_indices().await.unwrap().len()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: indexed_name_schema(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
let snapshot = reopened
|
|
|
|
|
|
.snapshot_of(ReadTarget::branch("main"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let dataset = snapshot.open("node:Person").await.unwrap();
|
|
|
|
|
|
let after_index_count = dataset.load_indices().await.unwrap().len();
|
|
|
|
|
|
assert!(after_index_count > before_index_count);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_rejects_unsupported_plan() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
2026-04-12 04:01:14 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: unsupported_schema_change(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
payload["code"],
|
|
|
|
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::BadRequest).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn schema_apply_route_rejects_when_non_main_branch_exists() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-12 04:01:14 +03:00
|
|
|
|
db.branch_create("feature").await.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, SCHEMA_APPLY_POLICY_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-12 04:01:14 +03:00
|
|
|
|
vec![("act-ragnor".to_string(), "admin-token".to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let request = Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: additive_schema_with_nickname(),
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
..Default::default()
|
2026-04-12 04:01:14 +03:00
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let (status, payload) = json_response(&app, request).await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
payload["code"],
|
|
|
|
|
|
serde_json::to_value(omnigraph_server::api::ErrorCode::Conflict).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
struct EnvGuard {
|
|
|
|
|
|
saved: Vec<(&'static str, Option<String>)>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl EnvGuard {
|
|
|
|
|
|
fn set(vars: &[(&'static str, Option<&str>)]) -> Self {
|
|
|
|
|
|
let saved = vars
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(name, _)| (*name, env::var(name).ok()))
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
for (name, value) in vars {
|
|
|
|
|
|
unsafe {
|
|
|
|
|
|
match value {
|
|
|
|
|
|
Some(value) => env::set_var(name, value),
|
|
|
|
|
|
None => env::remove_var(name),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Self { saved }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Drop for EnvGuard {
|
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
|
for (name, value) in self.saved.drain(..) {
|
|
|
|
|
|
unsafe {
|
|
|
|
|
|
match value {
|
|
|
|
|
|
Some(value) => env::set_var(name, value),
|
|
|
|
|
|
None => env::remove_var(name),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn format_vector(values: &[f32]) -> String {
|
|
|
|
|
|
values
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|value| format!("{:.8}", value))
|
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
|
.join(", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn normalize_vector(mut values: Vec<f32>) -> Vec<f32> {
|
|
|
|
|
|
let norm = values
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|value| (*value as f64) * (*value as f64))
|
|
|
|
|
|
.sum::<f64>()
|
|
|
|
|
|
.sqrt() as f32;
|
|
|
|
|
|
if norm > f32::EPSILON {
|
|
|
|
|
|
for value in &mut values {
|
|
|
|
|
|
*value /= norm;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
values
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn fnv1a64(bytes: &[u8]) -> u64 {
|
|
|
|
|
|
let mut hash = 14695981039346656037u64;
|
|
|
|
|
|
for byte in bytes {
|
|
|
|
|
|
hash ^= *byte as u64;
|
|
|
|
|
|
hash = hash.wrapping_mul(1099511628211u64);
|
|
|
|
|
|
}
|
|
|
|
|
|
hash
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn xorshift64(mut x: u64) -> u64 {
|
|
|
|
|
|
x ^= x << 13;
|
|
|
|
|
|
x ^= x >> 7;
|
|
|
|
|
|
x ^= x << 17;
|
|
|
|
|
|
x
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mock_embedding(input: &str, dim: usize) -> Vec<f32> {
|
|
|
|
|
|
let mut seed = fnv1a64(input.as_bytes());
|
|
|
|
|
|
let mut out = Vec::with_capacity(dim);
|
|
|
|
|
|
for _ in 0..dim {
|
|
|
|
|
|
seed = xorshift64(seed);
|
|
|
|
|
|
let ratio = (seed as f64 / u64::MAX as f64) as f32;
|
|
|
|
|
|
out.push((ratio * 2.0) - 1.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
normalize_vector(out)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn healthz_succeeds_after_startup() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/healthz")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(body["status"], "ok");
|
|
|
|
|
|
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
|
|
|
|
|
|
match option_env!("OMNIGRAPH_SOURCE_VERSION") {
|
|
|
|
|
|
Some(source_version) => assert_eq!(body["source_version"], source_version),
|
|
|
|
|
|
None => assert!(body.get("source_version").is_none()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_drift_returns_conflict_for_snapshot_read_and_change() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
fs::write(graph.join("_schema.pg"), drifted_test_schema()).unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let snapshot_error: ErrorOutput = serde_json::from_value(snapshot_body).unwrap();
|
|
|
|
|
|
assert_eq!(snapshot_status, StatusCode::CONFLICT);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
snapshot_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
snapshot_error
|
|
|
|
|
|
.error
|
|
|
|
|
|
.contains("schema evolution is locked down in phase 1")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let read = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Alice" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (read_status, read_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let read_error: ErrorOutput = serde_json::from_value(read_body).unwrap();
|
|
|
|
|
|
assert_eq!(read_status, StatusCode::CONFLICT);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
read_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
read_error
|
|
|
|
|
|
.error
|
|
|
|
|
|
.contains("schema evolution is locked down in phase 1")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (change_status, change_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let change_error: ErrorOutput = serde_json::from_value(change_body).unwrap();
|
|
|
|
|
|
assert_eq!(change_status, StatusCode::CONFLICT);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
change_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Conflict)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
change_error
|
|
|
|
|
|
.error
|
|
|
|
|
|
.contains("schema evolution is locked down in phase 1")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn protected_routes_require_bearer_token() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
2026-04-30 08:52:50 +02:00
|
|
|
|
.uri("/branches")
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn protected_routes_accept_valid_bearer_token_while_healthz_stays_open() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let health = app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/healthz")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(health.status(), StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
2026-04-30 08:52:50 +02:00
|
|
|
|
.uri("/branches")
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer demo-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
2026-04-30 08:52:50 +02:00
|
|
|
|
assert!(body["branches"].is_array());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn export_route_returns_jsonl_for_branch_snapshot() {
|
|
|
|
|
|
let token = "demo-token";
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
db.load(
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
r#"{"type":"Person","data":{"name":"Eve","age":29}}"#,
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let expected = db
|
|
|
|
|
|
.export_jsonl("feature", &["Person".to_string()], &[])
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
// MR-723: tokens-without-policy is now default-deny. Install a
|
|
|
|
|
|
// permit-all policy alongside the bearer token so /export
|
|
|
|
|
|
// (action=Export) passes Cedar evaluation. The test is exercising
|
|
|
|
|
|
// export semantics, not policy — the policy is just enough to clear
|
|
|
|
|
|
// the State 3 path.
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, permit_all_policy_yaml(&["default"])).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
vec![("default".to_string(), token.to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let response = app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/export")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", format!("Bearer {}", token))
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&ExportRequest {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
type_names: vec!["Person".to_string()],
|
|
|
|
|
|
table_keys: Vec::new(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
response.headers().get("content-type").unwrap(),
|
|
|
|
|
|
"application/x-ndjson; charset=utf-8"
|
|
|
|
|
|
);
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let text = String::from_utf8(body.to_vec()).unwrap();
|
|
|
|
|
|
assert_eq!(text, expected);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn protected_routes_accept_any_configured_team_bearer_token() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[
|
|
|
|
|
|
("team-01", "token-one"),
|
|
|
|
|
|
("team-02", "token-two"),
|
|
|
|
|
|
])
|
|
|
|
|
|
.await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
2026-04-30 08:52:50 +02:00
|
|
|
|
.uri("/branches")
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-two")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
2026-04-30 08:52:50 +02:00
|
|
|
|
assert!(body["branches"].is_array());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 21:40:51 +03:00
|
|
|
|
/// Verifies the hashed-token lookup correctly resolves each bearer to its
|
|
|
|
|
|
/// associated actor, and that the resolved actor — not the handler-supplied
|
|
|
|
|
|
/// default — is what the policy engine sees. Two tokens for two distinct
|
|
|
|
|
|
/// actors; policy grants read to actor-A only. Swapping tokens must swap
|
|
|
|
|
|
/// the policy outcome.
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn bearer_token_resolves_to_correct_actor_for_policy_decisions() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-17 21:40:51 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&policy_path,
|
|
|
|
|
|
r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
readers: [act-a]
|
|
|
|
|
|
writers: [act-b]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: readers-only
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: readers }
|
|
|
|
|
|
actions: [read]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-17 21:40:51 +03:00
|
|
|
|
vec![
|
|
|
|
|
|
("act-a".to_string(), "token-a".to_string()),
|
|
|
|
|
|
("act-b".to_string(), "token-b".to_string()),
|
|
|
|
|
|
],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
// act-a is authenticated AND authorized.
|
|
|
|
|
|
let (ok_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-a")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(ok_status, StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
|
|
// act-b is authenticated but policy rejects — proves the resolved actor
|
|
|
|
|
|
// (not some default) was the policy subject.
|
|
|
|
|
|
let (denied_status, denied_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-b")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let denied_error: ErrorOutput = serde_json::from_value(denied_body).unwrap();
|
|
|
|
|
|
assert_eq!(denied_status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
denied_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Unknown token: 401, never reaches the policy engine.
|
|
|
|
|
|
let (bad_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer wrong-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(bad_status, StatusCode::UNAUTHORIZED);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
policy: codify signed-token-claim-only actor identity (MR-731) (#101)
Warm-up commit for the policy chassis epic (MR-722). PR #1 of the
chassis series — same role as schema-lint v1's commit #1 baseline.
Zero behavioral change; establishes the regression test, the
load-bearing doc comment, and the user-doc paragraph for an
invariant already true in code.
Server auth already resolves `actor_id` from the matched bearer
token at `omnigraph-server/src/lib.rs:692-694`, overwriting whatever
the handler put in the PolicyRequest. The principle is named in
docs/dev/invariants.md Hard Invariant 11 ("clients cannot set actor
identity directly"). What was missing: a regression test, a
load-bearing doc comment at the resolution site, and a user-facing
documentation paragraph. This commit adds all three.
Why first. The actor-identity invariant is the foundation every
other policy decision stands on. If `actor_id` can be spoofed, every
chassis primitive (per-row scope, audit log, two-person rule)
becomes ungated. Pinning the invariant first means PR #2 (the
chassis core) doesn't have to re-prove this assertion.
Changes:
* crates/omnigraph-server/tests/server.rs — new regression test
actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers
with three sub-assertions:
- spoof-up: bearer for denied actor + X-Actor-Id naming allowed
actor → 403 (header doesn't promote)
- spoof-down: bearer for allowed actor + X-Actor-Id naming denied
actor → 200 (header doesn't demote)
- empty-string spoof: empty X-Actor-Id doesn't clear resolved actor
Cross-link to MR-777 (auth boundary cases — actor-id collision +
malformed bearer) noted in the test docstring.
* crates/omnigraph-server/src/lib.rs — expanded doc comment at
the actor-resolution site explaining the SECURITY INVARIANT,
citing Hard Invariant 11, the Supabase RLS history footgun, and
the regression test that pins the contract. Reader thinking "I
should let clients override actor_id for impersonation" hits
this comment first.
* docs/user/policy.md — new "Actor identity (signed-claim-only)"
section near the existing Server enforcement section. Closes the
user-facing doc gap MR-731's "Done when" requires.
Architectural decisions for PR #2+ pinned this session (not
implemented here, recorded so future implementers don't re-litigate):
- PolicyEngine moves to new `omnigraph-policy` workspace crate so
both engine and server can depend on it (Q2).
- `enforce(action, scope, actor)` will take a new `ResourceScope`
enum, leaving room for MR-725's per-type and per-row variants (Q3).
- `PolicyAction::Admin` is kept and wired (Option A) — meta-action
for policy-management surfaces (hot reload, audit log query,
approvals list) as those consumer features land (Q4).
Test results:
- cargo test -p omnigraph-server --test server: 45 pass (44 existing
+ 1 new); no regressions
- scripts/check-agents-md.sh: passes (34 links / 33 docs OK)
Out of scope (PR #2+):
- Omnigraph::with_policy() + enforce() method
- omnigraph-policy crate creation
- ResourceScope enum
- CLI policy injection into Omnigraph
- HTTP-layer redundant-check removal
- MR-724 Admin action wiring (PR #2)
- MR-723 default-deny 3-state (PR #4)
- MR-736 severity warn/deny (PR #5)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 02:51:34 +03:00
|
|
|
|
/// Regression test for MR-731: actor identity comes from the matched
|
|
|
|
|
|
/// bearer token, never from a client-supplied request header. A future
|
|
|
|
|
|
/// "convenience" PR that lets clients override `actor_id` to spoof
|
|
|
|
|
|
/// another identity must break this test. The principle is named in
|
|
|
|
|
|
/// `docs/dev/invariants.md` Hard Invariant 11 and at the actor-resolution
|
|
|
|
|
|
/// site in `omnigraph-server/src/lib.rs::authorize_request`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Two assertions in one fixture:
|
|
|
|
|
|
/// 1. Spoof-up: bearer for a *denied* actor + X-Actor-Id naming an
|
|
|
|
|
|
/// *allowed* actor — policy still denies (proves the spoof header
|
|
|
|
|
|
/// doesn't promote the request).
|
|
|
|
|
|
/// 2. Spoof-down: bearer for an *allowed* actor + X-Actor-Id naming a
|
|
|
|
|
|
/// *denied* actor — policy still allows (proves the server-resolved
|
|
|
|
|
|
/// identity wins; the spoof can't trick the request into a denial
|
|
|
|
|
|
/// either, which would otherwise be a confusing UX trap).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Cross-reference: MR-777 covers boundary cases like actor-id
|
|
|
|
|
|
/// *collision* (two distinct tokens minting the same actor_id) and
|
|
|
|
|
|
/// malformed bearer header parsing. See `auth_boundary_case_coverage`
|
|
|
|
|
|
/// suite when it lands; the two tests together pin the full bearer-token
|
|
|
|
|
|
/// → actor identity contract.
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
policy: codify signed-token-claim-only actor identity (MR-731) (#101)
Warm-up commit for the policy chassis epic (MR-722). PR #1 of the
chassis series — same role as schema-lint v1's commit #1 baseline.
Zero behavioral change; establishes the regression test, the
load-bearing doc comment, and the user-doc paragraph for an
invariant already true in code.
Server auth already resolves `actor_id` from the matched bearer
token at `omnigraph-server/src/lib.rs:692-694`, overwriting whatever
the handler put in the PolicyRequest. The principle is named in
docs/dev/invariants.md Hard Invariant 11 ("clients cannot set actor
identity directly"). What was missing: a regression test, a
load-bearing doc comment at the resolution site, and a user-facing
documentation paragraph. This commit adds all three.
Why first. The actor-identity invariant is the foundation every
other policy decision stands on. If `actor_id` can be spoofed, every
chassis primitive (per-row scope, audit log, two-person rule)
becomes ungated. Pinning the invariant first means PR #2 (the
chassis core) doesn't have to re-prove this assertion.
Changes:
* crates/omnigraph-server/tests/server.rs — new regression test
actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers
with three sub-assertions:
- spoof-up: bearer for denied actor + X-Actor-Id naming allowed
actor → 403 (header doesn't promote)
- spoof-down: bearer for allowed actor + X-Actor-Id naming denied
actor → 200 (header doesn't demote)
- empty-string spoof: empty X-Actor-Id doesn't clear resolved actor
Cross-link to MR-777 (auth boundary cases — actor-id collision +
malformed bearer) noted in the test docstring.
* crates/omnigraph-server/src/lib.rs — expanded doc comment at
the actor-resolution site explaining the SECURITY INVARIANT,
citing Hard Invariant 11, the Supabase RLS history footgun, and
the regression test that pins the contract. Reader thinking "I
should let clients override actor_id for impersonation" hits
this comment first.
* docs/user/policy.md — new "Actor identity (signed-claim-only)"
section near the existing Server enforcement section. Closes the
user-facing doc gap MR-731's "Done when" requires.
Architectural decisions for PR #2+ pinned this session (not
implemented here, recorded so future implementers don't re-litigate):
- PolicyEngine moves to new `omnigraph-policy` workspace crate so
both engine and server can depend on it (Q2).
- `enforce(action, scope, actor)` will take a new `ResourceScope`
enum, leaving room for MR-725's per-type and per-row variants (Q3).
- `PolicyAction::Admin` is kept and wired (Option A) — meta-action
for policy-management surfaces (hot reload, audit log query,
approvals list) as those consumer features land (Q4).
Test results:
- cargo test -p omnigraph-server --test server: 45 pass (44 existing
+ 1 new); no regressions
- scripts/check-agents-md.sh: passes (34 links / 33 docs OK)
Out of scope (PR #2+):
- Omnigraph::with_policy() + enforce() method
- omnigraph-policy crate creation
- ResourceScope enum
- CLI policy injection into Omnigraph
- HTTP-layer redundant-check removal
- MR-724 Admin action wiring (PR #2)
- MR-723 default-deny 3-state (PR #4)
- MR-736 severity warn/deny (PR #5)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 02:51:34 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
// Same readers/writers split as
|
|
|
|
|
|
// `bearer_token_resolves_to_correct_actor_for_policy_decisions` —
|
|
|
|
|
|
// `act-a` can read main, `act-b` cannot. The asymmetry is what
|
|
|
|
|
|
// makes the spoof-up/spoof-down distinction observable.
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&policy_path,
|
|
|
|
|
|
r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
readers: [act-a]
|
|
|
|
|
|
writers: [act-b]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: readers-only
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: readers }
|
|
|
|
|
|
actions: [read]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
policy: codify signed-token-claim-only actor identity (MR-731) (#101)
Warm-up commit for the policy chassis epic (MR-722). PR #1 of the
chassis series — same role as schema-lint v1's commit #1 baseline.
Zero behavioral change; establishes the regression test, the
load-bearing doc comment, and the user-doc paragraph for an
invariant already true in code.
Server auth already resolves `actor_id` from the matched bearer
token at `omnigraph-server/src/lib.rs:692-694`, overwriting whatever
the handler put in the PolicyRequest. The principle is named in
docs/dev/invariants.md Hard Invariant 11 ("clients cannot set actor
identity directly"). What was missing: a regression test, a
load-bearing doc comment at the resolution site, and a user-facing
documentation paragraph. This commit adds all three.
Why first. The actor-identity invariant is the foundation every
other policy decision stands on. If `actor_id` can be spoofed, every
chassis primitive (per-row scope, audit log, two-person rule)
becomes ungated. Pinning the invariant first means PR #2 (the
chassis core) doesn't have to re-prove this assertion.
Changes:
* crates/omnigraph-server/tests/server.rs — new regression test
actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers
with three sub-assertions:
- spoof-up: bearer for denied actor + X-Actor-Id naming allowed
actor → 403 (header doesn't promote)
- spoof-down: bearer for allowed actor + X-Actor-Id naming denied
actor → 200 (header doesn't demote)
- empty-string spoof: empty X-Actor-Id doesn't clear resolved actor
Cross-link to MR-777 (auth boundary cases — actor-id collision +
malformed bearer) noted in the test docstring.
* crates/omnigraph-server/src/lib.rs — expanded doc comment at
the actor-resolution site explaining the SECURITY INVARIANT,
citing Hard Invariant 11, the Supabase RLS history footgun, and
the regression test that pins the contract. Reader thinking "I
should let clients override actor_id for impersonation" hits
this comment first.
* docs/user/policy.md — new "Actor identity (signed-claim-only)"
section near the existing Server enforcement section. Closes the
user-facing doc gap MR-731's "Done when" requires.
Architectural decisions for PR #2+ pinned this session (not
implemented here, recorded so future implementers don't re-litigate):
- PolicyEngine moves to new `omnigraph-policy` workspace crate so
both engine and server can depend on it (Q2).
- `enforce(action, scope, actor)` will take a new `ResourceScope`
enum, leaving room for MR-725's per-type and per-row variants (Q3).
- `PolicyAction::Admin` is kept and wired (Option A) — meta-action
for policy-management surfaces (hot reload, audit log query,
approvals list) as those consumer features land (Q4).
Test results:
- cargo test -p omnigraph-server --test server: 45 pass (44 existing
+ 1 new); no regressions
- scripts/check-agents-md.sh: passes (34 links / 33 docs OK)
Out of scope (PR #2+):
- Omnigraph::with_policy() + enforce() method
- omnigraph-policy crate creation
- ResourceScope enum
- CLI policy injection into Omnigraph
- HTTP-layer redundant-check removal
- MR-724 Admin action wiring (PR #2)
- MR-723 default-deny 3-state (PR #4)
- MR-736 severity warn/deny (PR #5)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 02:51:34 +03:00
|
|
|
|
vec![
|
|
|
|
|
|
("act-a".to_string(), "token-a".to_string()),
|
|
|
|
|
|
("act-b".to_string(), "token-b".to_string()),
|
|
|
|
|
|
],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
// (1) Spoof-up: bearer for act-b (denied) + X-Actor-Id: act-a (allowed).
|
|
|
|
|
|
// If the server were trusting the header, this would succeed as
|
|
|
|
|
|
// act-a. The contract is: the bearer wins. Expect 403 because
|
|
|
|
|
|
// act-b can't read.
|
|
|
|
|
|
let (spoof_up_status, spoof_up_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-b")
|
|
|
|
|
|
.header("x-actor-id", "act-a")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let spoof_up_error: ErrorOutput = serde_json::from_value(spoof_up_body).unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
spoof_up_status,
|
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
|
"X-Actor-Id must not promote a denied bearer to an allowed actor",
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
spoof_up_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// (2) Spoof-down: bearer for act-a (allowed) + X-Actor-Id: act-b (denied).
|
|
|
|
|
|
// If the server were trusting the header, this would fail as act-b.
|
|
|
|
|
|
// The contract is: the bearer wins. Expect 200 because act-a can read.
|
|
|
|
|
|
let (spoof_down_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-a")
|
|
|
|
|
|
.header("x-actor-id", "act-b")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
spoof_down_status,
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
"X-Actor-Id must not demote an allowed bearer to a denied actor",
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// (3) Empty-string spoof attempt: an X-Actor-Id of "" must not
|
|
|
|
|
|
// leak through as the policy subject. Same expectation as (1):
|
|
|
|
|
|
// bearer for act-b is denied regardless of what the header tries.
|
|
|
|
|
|
let (empty_spoof_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-b")
|
|
|
|
|
|
.header("x-actor-id", "")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
empty_spoof_status,
|
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
|
"empty X-Actor-Id must not clear the resolved actor",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_allows_read_but_distinguishes_401_from_403() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
&[("act-bruno", "team-token"), ("act-ragnor", "admin-token")],
|
|
|
|
|
|
POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let (missing_status, missing_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap();
|
|
|
|
|
|
assert_eq!(missing_status, StatusCode::UNAUTHORIZED);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
missing_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(snapshot_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(snapshot_body["branch"], "main");
|
|
|
|
|
|
|
|
|
|
|
|
let export_request = ExportRequest {
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
type_names: Vec::new(),
|
|
|
|
|
|
table_keys: Vec::new(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (forbidden_status, forbidden_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/export")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&export_request).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let forbidden_error: ErrorOutput = serde_json::from_value(forbidden_body).unwrap();
|
|
|
|
|
|
assert_eq!(forbidden_status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
forbidden_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let response = app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/export")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&export_request).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_uses_resolved_branch_for_snapshot_reads() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let snapshot_id = {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.resolve_snapshot("main").await.unwrap().to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, POLICY_PROTECTED_READ_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
vec![("act-bruno".to_string(), "team-token".to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let read = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Alice" })),
|
|
|
|
|
|
branch: None,
|
|
|
|
|
|
snapshot: Some(snapshot_id),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(body["target"]["branch"], Value::Null);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
body["target"]["snapshot"].as_str(),
|
|
|
|
|
|
read.snapshot.as_deref()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(body["row_count"], 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn snapshot_route_returns_manifest_dataset_version() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let expected_manifest_version = manifest_dataset_version(&graph).await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(snapshot_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(snapshot_body["branch"], "main");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
snapshot_body["manifest_version"].as_u64().unwrap(),
|
|
|
|
|
|
expected_manifest_version
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(snapshot_body["tables"].is_array());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 00:30:46 +03:00
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_route_returns_current_source() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-18 00:30:46 +03:00
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/schema")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
let output: SchemaOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert!(output.schema_source.contains("node Person"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_route_requires_bearer_token_when_auth_configured() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
2026-04-18 00:30:46 +03:00
|
|
|
|
|
|
|
|
|
|
let (missing_status, missing_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/schema")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap();
|
|
|
|
|
|
assert_eq!(missing_status, StatusCode::UNAUTHORIZED);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
missing_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (ok_status, ok_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/schema")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer demo-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(ok_status, StatusCode::OK);
|
|
|
|
|
|
let output: SchemaOutput = serde_json::from_value(ok_body).unwrap();
|
|
|
|
|
|
assert!(!output.schema_source.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_route_denied_when_actor_lacks_read_permission() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-18 00:30:46 +03:00
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
// Policy grants branch_create only — no read action for act-bruno.
|
|
|
|
|
|
fs::write(&policy_path, INGEST_CREATE_ONLY_POLICY_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-18 00:30:46 +03:00
|
|
|
|
vec![("act-bruno".to_string(), "team-token".to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/schema")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, POLICY_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
vec![("act-bruno".to_string(), "team-token".to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let main_change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (main_status, main_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&main_change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let main_error: ErrorOutput = serde_json::from_value(main_body).unwrap();
|
|
|
|
|
|
assert_eq!(main_status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
main_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let feature_change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (feature_status, feature_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&feature_change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(feature_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(feature_body["branch"], "feature");
|
|
|
|
|
|
assert_eq!(feature_body["affected_nodes"], 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_blocks_non_admin_merge_to_main_and_allows_admin() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
db.load(
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, POLICY_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
vec![
|
|
|
|
|
|
("act-bruno".to_string(), "team-token".to_string()),
|
|
|
|
|
|
("act-ragnor".to_string(), "admin-token".to_string()),
|
|
|
|
|
|
],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let merge = BranchMergeRequest {
|
|
|
|
|
|
source: "feature".to_string(),
|
|
|
|
|
|
target: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (deny_status, deny_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let deny_error: ErrorOutput = serde_json::from_value(deny_body).unwrap();
|
|
|
|
|
|
assert_eq!(deny_status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
deny_error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (allow_status, allow_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(allow_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(allow_body["actor_id"], "act-ragnor");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
2026-04-30 08:52:50 +02:00
|
|
|
|
async fn authenticated_change_stamps_actor_on_commits() {
|
2026-05-01 22:45:38 +02:00
|
|
|
|
// With the Run state machine removed, actor_id is recorded
|
2026-04-30 08:52:50 +02:00
|
|
|
|
// directly on the commit graph (no intermediate run record).
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[("act-andrew", "token-one")]).await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (change_status, change_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer token-one")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(change_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(change_body["actor_id"], "act-andrew");
|
|
|
|
|
|
|
|
|
|
|
|
let (commits_status, commits_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/commits?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-one")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(commits_status, StatusCode::OK);
|
|
|
|
|
|
let head = commits_body["commits"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.expect("head commit should exist");
|
|
|
|
|
|
assert_eq!(head["actor_id"], "act-andrew");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn ingest_creates_branch_returns_metadata_and_stamps_actor() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_loaded_graph_with_auth_tokens(&[("act-andrew", "token-one")]).await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let ingest = IngestRequest {
|
|
|
|
|
|
branch: Some("feature-ingest".to_string()),
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
mode: Some(LoadMode::Merge),
|
|
|
|
|
|
data: r#"{"type":"Person","data":{"name":"Zoe","age":33}}
|
|
|
|
|
|
{"type":"Person","data":{"name":"Bob","age":26}}"#
|
|
|
|
|
|
.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer token-one")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&ingest).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(body["branch"], "feature-ingest");
|
|
|
|
|
|
assert_eq!(body["base_branch"], "main");
|
|
|
|
|
|
assert_eq!(body["branch_created"], true);
|
|
|
|
|
|
assert_eq!(body["mode"], "merge");
|
|
|
|
|
|
assert_eq!(body["actor_id"], "act-andrew");
|
|
|
|
|
|
assert_eq!(body["tables"][0]["table_key"], "node:Person");
|
|
|
|
|
|
assert_eq!(body["tables"][0]["rows_loaded"], 2);
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let snapshot = db
|
|
|
|
|
|
.snapshot_of(ReadTarget::branch("feature-ingest"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let person_ds = snapshot.open("node:Person").await.unwrap();
|
|
|
|
|
|
assert_eq!(person_ds.count_rows(None).await.unwrap(), 5);
|
|
|
|
|
|
let head = db
|
|
|
|
|
|
.list_commits(Some("feature-ingest"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(head.actor_id.as_deref(), Some("act-andrew"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn ingest_existing_branch_skips_branch_create_policy_check() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
{
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, POLICY_YAML).unwrap();
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-04-10 20:49:41 +03:00
|
|
|
|
vec![("act-bruno".to_string(), "team-token".to_string())],
|
|
|
|
|
|
Some(&policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
let ingest = IngestRequest {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
from: Some("other-base".to_string()),
|
|
|
|
|
|
mode: Some(LoadMode::Merge),
|
|
|
|
|
|
data: r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&ingest).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(body["branch"], "feature");
|
|
|
|
|
|
assert_eq!(body["branch_created"], false);
|
|
|
|
|
|
assert_eq!(body["base_branch"], "other-base");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn ingest_denies_missing_branch_without_branch_create_permission() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
&[("act-bruno", "team-token")],
|
|
|
|
|
|
POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let ingest = IngestRequest {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
mode: Some(LoadMode::Merge),
|
|
|
|
|
|
data: r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&ingest).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn ingest_denies_when_actor_lacks_change_permission() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
&[("act-bruno", "team-token")],
|
|
|
|
|
|
INGEST_CREATE_ONLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let ingest = IngestRequest {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
mode: Some(LoadMode::Merge),
|
|
|
|
|
|
data: r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#.to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer team-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&ingest).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn ingest_rejects_payloads_over_32_mib() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let oversize = IngestRequest {
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
mode: Some(LoadMode::Merge),
|
|
|
|
|
|
data: "x".repeat(33 * 1024 * 1024),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let response = app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&oversize).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn authenticated_branch_merge_stamps_merge_actor_on_head_commit() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[
|
2026-04-10 20:49:41 +03:00
|
|
|
|
("act-andrew", "token-one"),
|
|
|
|
|
|
("act-ragnor", "token-two"),
|
|
|
|
|
|
])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let create = BranchCreateRequest {
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
name: "feature".to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (create_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer token-one")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&create).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(create_status, StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Zoe", "age": 33 })),
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (change_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer token-one")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(change_status, StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
|
|
let merge = BranchMergeRequest {
|
|
|
|
|
|
source: "feature".to_string(),
|
|
|
|
|
|
target: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (merge_status, merge_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer token-two")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(merge_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(merge_body["actor_id"], "act-ragnor");
|
|
|
|
|
|
|
|
|
|
|
|
let (commit_status, commit_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/commits?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer token-two")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(commit_status, StatusCode::OK);
|
|
|
|
|
|
let head = commit_body["commits"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.expect("head commit should exist");
|
|
|
|
|
|
assert_eq!(head["actor_id"], "act-ragnor");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn branch_merge_conflict_response_includes_structured_conflicts() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
db.mutate(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
MUTATION_QUERIES,
|
|
|
|
|
|
"set_age",
|
|
|
|
|
|
&omnigraph_compiler::json_params_to_param_map(
|
|
|
|
|
|
Some(&json!({"name": "Alice", "age": 31 })),
|
|
|
|
|
|
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.params,
|
|
|
|
|
|
omnigraph_compiler::JsonParamMode::Standard,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
db.mutate(
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
MUTATION_QUERIES,
|
|
|
|
|
|
"set_age",
|
|
|
|
|
|
&omnigraph_compiler::json_params_to_param_map(
|
|
|
|
|
|
Some(&json!({"name": "Alice", "age": 32 })),
|
|
|
|
|
|
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.params,
|
|
|
|
|
|
omnigraph_compiler::JsonParamMode::Standard,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
let merge = BranchMergeRequest {
|
|
|
|
|
|
source: "feature".to_string(),
|
|
|
|
|
|
target: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
|
|
|
|
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::Conflict));
|
|
|
|
|
|
assert!(error.error.contains("merge conflict"));
|
|
|
|
|
|
assert!(error.merge_conflicts.iter().any(|conflict| {
|
|
|
|
|
|
conflict.table_key == "node:Person"
|
|
|
|
|
|
&& conflict.row_id.as_deref() == Some("Alice")
|
|
|
|
|
|
&& conflict.kind == omnigraph_server::api::MergeConflictKindOutput::DivergentUpdate
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn repeated_read_after_change_sees_updated_state_from_same_app() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina", "age": 28 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (change_status, change_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(change_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(change_body["affected_nodes"], 1);
|
|
|
|
|
|
|
|
|
|
|
|
let read = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Mina" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (read_status, read_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(read_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(read_body["row_count"], 1);
|
|
|
|
|
|
assert_eq!(read_body["rows"][0]["p.name"], "Mina");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn remote_branch_list_create_merge_flow_works() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let (list_status, list_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(list_body["branches"], json!(["main"]));
|
|
|
|
|
|
|
|
|
|
|
|
let create = BranchCreateRequest {
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
name: "feature".to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (create_status, create_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&create).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(create_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(create_body["from"], "main");
|
|
|
|
|
|
assert_eq!(create_body["name"], "feature");
|
|
|
|
|
|
|
|
|
|
|
|
let (list_status, list_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(list_body["branches"], json!(["feature", "main"]));
|
|
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Zoe", "age": 33 })),
|
|
|
|
|
|
branch: Some("feature".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (change_status, change_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(change_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(change_body["branch"], "feature");
|
|
|
|
|
|
assert_eq!(change_body["affected_nodes"], 1);
|
|
|
|
|
|
|
|
|
|
|
|
let read_main_before = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Zoe" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (read_status, read_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read_main_before).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(read_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(read_body["row_count"], 0);
|
|
|
|
|
|
|
|
|
|
|
|
let merge = BranchMergeRequest {
|
|
|
|
|
|
source: "feature".to_string(),
|
|
|
|
|
|
target: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (merge_status, merge_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(merge_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(merge_body["source"], "feature");
|
|
|
|
|
|
assert_eq!(merge_body["target"], "main");
|
|
|
|
|
|
assert_eq!(merge_body["outcome"], "fast_forward");
|
|
|
|
|
|
|
|
|
|
|
|
let read_main_after = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Zoe" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (read_status, read_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read_main_after).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(read_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(read_body["row_count"], 1);
|
|
|
|
|
|
assert_eq!(read_body["rows"][0]["p.name"], "Zoe");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn remote_branch_delete_flow_works() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
|
|
|
|
|
let create = BranchCreateRequest {
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
name: "feature".to_string(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (create_status, _) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&create).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(create_status, StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
|
|
let (delete_status, delete_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/feature")
|
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(delete_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(delete_body["name"], "feature");
|
|
|
|
|
|
|
|
|
|
|
|
let (list_status, list_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(list_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(list_body["branches"], json!(["main"]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn branch_delete_denies_without_policy_permission() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
2026-04-10 20:49:41 +03:00
|
|
|
|
&[("act-andrew", "token-admin"), ("act-bruno", "token-team")],
|
|
|
|
|
|
POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
drop(db);
|
|
|
|
|
|
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/feature")
|
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
|
.header("authorization", "Bearer token-team")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
body["error"]
|
|
|
|
|
|
.as_str()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.contains("policy denied action 'branch_delete'")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn server_opens_s3_graph_directly_and_serves_snapshot_and_read() {
|
|
|
|
|
|
let Some(uri) = s3_test_graph_uri("server") else {
|
2026-04-10 20:49:41 +03:00
|
|
|
|
eprintln!("skipping s3 server test: OMNIGRAPH_S3_TEST_BUCKET is not set");
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Omnigraph::init(&uri, &fs::read_to_string(fixture("test.pg")).unwrap())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let mut db = Omnigraph::open(&uri).await.unwrap();
|
|
|
|
|
|
load_jsonl(
|
|
|
|
|
|
&mut db,
|
|
|
|
|
|
&fs::read_to_string(fixture("test.jsonl")).unwrap(),
|
|
|
|
|
|
LoadMode::Overwrite,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let app = build_app(
|
|
|
|
|
|
AppState::open_with_bearer_token(uri.clone(), Some("s3-token".to_string()))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header("authorization", "Bearer s3-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(snapshot_status, StatusCode::OK);
|
|
|
|
|
|
assert!(snapshot_body["tables"].is_array());
|
|
|
|
|
|
|
|
|
|
|
|
let read = ReadRequest {
|
|
|
|
|
|
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
|
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Alice" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (read_status, read_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer s3-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(read_status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(read_body["row_count"], 1);
|
|
|
|
|
|
assert_eq!(read_body["rows"][0]["p.name"], "Alice");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
#[serial]
|
|
|
|
|
|
async fn remote_read_embeds_string_nearest_queries_with_mock_runtime() {
|
|
|
|
|
|
const EMBED_SCHEMA: &str = r#"
|
|
|
|
|
|
node Doc {
|
|
|
|
|
|
slug: String @key
|
|
|
|
|
|
title: String @index
|
|
|
|
|
|
embedding: Vector(4) @index
|
|
|
|
|
|
}
|
|
|
|
|
|
"#;
|
|
|
|
|
|
const EMBED_QUERY: &str = r#"
|
|
|
|
|
|
query vector_search_string($q: String) {
|
|
|
|
|
|
match { $d: Doc }
|
|
|
|
|
|
return { $d.slug, $d.title }
|
|
|
|
|
|
order { nearest($d.embedding, $q) }
|
|
|
|
|
|
limit 3
|
|
|
|
|
|
}
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
let alpha = mock_embedding("alpha", 4);
|
|
|
|
|
|
let beta = mock_embedding("beta", 4);
|
|
|
|
|
|
let gamma = mock_embedding("gamma", 4);
|
|
|
|
|
|
let data = format!(
|
|
|
|
|
|
concat!(
|
|
|
|
|
|
r#"{{"type":"Doc","data":{{"slug":"alpha-doc","title":"alpha guide","embedding":[{}]}}}}"#,
|
|
|
|
|
|
"\n",
|
|
|
|
|
|
r#"{{"type":"Doc","data":{{"slug":"beta-doc","title":"beta guide","embedding":[{}]}}}}"#,
|
|
|
|
|
|
"\n",
|
|
|
|
|
|
r#"{{"type":"Doc","data":{{"slug":"gamma-doc","title":"gamma handbook","embedding":[{}]}}}}"#
|
|
|
|
|
|
),
|
|
|
|
|
|
format_vector(&alpha),
|
|
|
|
|
|
format_vector(&beta),
|
|
|
|
|
|
format_vector(&gamma),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let _guard = EnvGuard::set(&[
|
|
|
|
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", Some("1")),
|
|
|
|
|
|
("GEMINI_API_KEY", None),
|
|
|
|
|
|
]);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_graph_with_schema_and_data(EMBED_SCHEMA, &data).await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let read = ReadRequest {
|
|
|
|
|
|
query_source: EMBED_QUERY.to_string(),
|
|
|
|
|
|
query_name: Some("vector_search_string".to_string()),
|
|
|
|
|
|
params: Some(json!({ "q": "alpha" })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(body["row_count"], 3);
|
|
|
|
|
|
assert_eq!(body["rows"][0]["d.slug"], "alpha-doc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
2026-04-30 08:52:50 +02:00
|
|
|
|
async fn change_conflict_returns_manifest_conflict_409() {
|
2026-05-01 22:45:38 +02:00
|
|
|
|
// A write that races with another writer surfaces as HTTP 409 with
|
|
|
|
|
|
// a structured `manifest_conflict` body — `table_key`, `expected`,
|
|
|
|
|
|
// and `actual` — so clients can detect-and-retry without parsing
|
|
|
|
|
|
// the message.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
2026-04-30 08:52:50 +02:00
|
|
|
|
// Build the server first so its handle pins the pre-mutation manifest
|
|
|
|
|
|
// version. Then advance the manifest from outside the server. The
|
|
|
|
|
|
// server's next /change call will capture stale `expected_versions`
|
|
|
|
|
|
// (from its still-pinned snapshot) and the publisher's CAS rejects.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-04-30 08:52:50 +02:00
|
|
|
|
let app = build_app(state);
|
2026-04-10 20:49:41 +03:00
|
|
|
|
|
2026-04-30 08:52:50 +02:00
|
|
|
|
{
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let mut db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-04-30 08:52:50 +02:00
|
|
|
|
db.mutate(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
MUTATION_QUERIES,
|
|
|
|
|
|
"set_age",
|
|
|
|
|
|
&omnigraph_compiler::json_params_to_param_map(
|
|
|
|
|
|
Some(&json!({"name": "Alice", "age": 31 })),
|
|
|
|
|
|
&omnigraph_compiler::find_named_query(MUTATION_QUERIES, "set_age")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.params,
|
|
|
|
|
|
omnigraph_compiler::JsonParamMode::Standard,
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-04-30 08:52:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
2026-04-30 08:52:50 +02:00
|
|
|
|
.uri("/change")
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
2026-04-30 08:52:50 +02:00
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("set_age".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Alice", "age": 33 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
2026-04-10 20:49:41 +03:00
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
2026-04-30 08:52:50 +02:00
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
2026-04-10 20:49:41 +03:00
|
|
|
|
assert_eq!(error.code, Some(omnigraph_server::api::ErrorCode::Conflict));
|
2026-04-30 08:52:50 +02:00
|
|
|
|
let conflict = error
|
|
|
|
|
|
.manifest_conflict
|
|
|
|
|
|
.expect("publisher CAS rejection must populate manifest_conflict body");
|
|
|
|
|
|
assert_eq!(conflict.table_key, "node:Person");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
conflict.actual > conflict.expected,
|
|
|
|
|
|
"actual ({}) should be ahead of expected ({})",
|
|
|
|
|
|
conflict.actual,
|
|
|
|
|
|
conflict.expected,
|
|
|
|
|
|
);
|
2026-04-10 20:49:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
mr-686: Phase 2 — op-kind-aware version check + coord Mutex → RwLock
Fix A: op-kind-aware ensure_expected_version. Insert/Merge skip the
strict pre-stage check; Update/Delete/SchemaRewrite keep it. New
MutationOpKind enum threaded through open_for_mutation_on_branch /
open_owned_dataset_for_branch_write / reopen_for_mutation and all
callers (execute_insert/update/delete_node/delete_edge,
branch_merge::publish_rewritten_merge_table, schema_apply,
ensure_indices_for_branch, loader Append/Merge/Overwrite). Closes the
77% rejection rate on same-key concurrent inserts.
Fix B: coordinator Mutex -> RwLock. Reads parallelize via .read();
writes serialize via .write(). Atomic-commit invariant preserved by
the single .write() covering commit_manifest_updates +
record_graph_commit.
Bench-as-test change_concurrent_inserts_same_key_serialize_without_409
(server.rs:2180) spawns 12 concurrent /change inserts on a single
(table, branch); asserts every request returns 200. Was failing
pre-Phase-2; passes post-Phase-2.
change_conflict_returns_manifest_conflict_409 (cross-process drift
sentinel) and branch_merge_conflict_response_includes_structured_conflicts
both still pass.
Bench (after-pr2-phase2):
- single-actor 1x1: 14.9 ops/s, p50 68ms (baseline 12.3, +22%)
- disjoint 8x8: 7.04 ops/s, p50 1023ms (baseline 6.24, +13%)
- same-key 8x1: 2.62 ops/s, 0 errors (after-pr2: 77% errors)
Disjoint stayed at +13% — Fix B's RwLock helped read paths but the
publisher's .write() critical section still serializes graph-wide.
Splitting GraphCoordinator into per-concern primitives (manifest in
ArcSwap, commit_graph in RwLock, atomic-commit serializer) is the
deferred next step.
102 lib + 30 branching + 24 runs + 16 staged_writes + 63 end_to_end
+ 40 server tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:42:26 +02:00
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
|
async fn change_concurrent_inserts_same_key_serialize_without_409() {
|
|
|
|
|
|
// PR 2 Phase 2 (MR-686): pin the design fix for the same-key
|
|
|
|
|
|
// concurrency hazard. Pre-fix, in-process concurrent inserts on
|
|
|
|
|
|
// the same `(table, branch)` rejected with 409 manifest_conflict
|
|
|
|
|
|
// because `ensure_expected_version` fired before the per-table
|
|
|
|
|
|
// queue was acquired and saw Lance HEAD already advanced by a
|
|
|
|
|
|
// peer writer. Post-fix, Insert/Merge skip the strict pre-stage
|
|
|
|
|
|
// check (see `MutationOpKind::strict_pre_stage_version_check`);
|
|
|
|
|
|
// the queue serializes commit_staged; Lance's natural rebase
|
|
|
|
|
|
// handles the in-flight stage; the publisher's CAS on a fresh
|
|
|
|
|
|
// per-branch snapshot under the queue catches genuine cross-
|
|
|
|
|
|
// process drift.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This test spawns N concurrent /change inserts on a single
|
|
|
|
|
|
// node type and asserts: every request returns 200 (no 409),
|
2026-05-08 16:49:38 +02:00
|
|
|
|
// and the final row count equals the seed count + N (every
|
|
|
|
|
|
// staged batch actually committed).
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
mr-686: Phase 2 — op-kind-aware version check + coord Mutex → RwLock
Fix A: op-kind-aware ensure_expected_version. Insert/Merge skip the
strict pre-stage check; Update/Delete/SchemaRewrite keep it. New
MutationOpKind enum threaded through open_for_mutation_on_branch /
open_owned_dataset_for_branch_write / reopen_for_mutation and all
callers (execute_insert/update/delete_node/delete_edge,
branch_merge::publish_rewritten_merge_table, schema_apply,
ensure_indices_for_branch, loader Append/Merge/Overwrite). Closes the
77% rejection rate on same-key concurrent inserts.
Fix B: coordinator Mutex -> RwLock. Reads parallelize via .read();
writes serialize via .write(). Atomic-commit invariant preserved by
the single .write() covering commit_manifest_updates +
record_graph_commit.
Bench-as-test change_concurrent_inserts_same_key_serialize_without_409
(server.rs:2180) spawns 12 concurrent /change inserts on a single
(table, branch); asserts every request returns 200. Was failing
pre-Phase-2; passes post-Phase-2.
change_conflict_returns_manifest_conflict_409 (cross-process drift
sentinel) and branch_merge_conflict_response_includes_structured_conflicts
both still pass.
Bench (after-pr2-phase2):
- single-actor 1x1: 14.9 ops/s, p50 68ms (baseline 12.3, +22%)
- disjoint 8x8: 7.04 ops/s, p50 1023ms (baseline 6.24, +13%)
- same-key 8x1: 2.62 ops/s, 0 errors (after-pr2: 77% errors)
Disjoint stayed at +13% — Fix B's RwLock helped read paths but the
publisher's .write() critical section still serializes graph-wide.
Splitting GraphCoordinator into per-concern primitives (manifest in
ArcSwap, commit_graph in RwLock, atomic-commit serializer) is the
deferred next step.
102 lib + 30 branching + 24 runs + 16 staged_writes + 63 end_to_end
+ 40 server tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:42:26 +02:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
2026-05-08 16:49:38 +02:00
|
|
|
|
// test.jsonl seeds 4 Persons (Alice, Bob, Charlie, Diana).
|
|
|
|
|
|
const SEED_PERSON_ROWS: u64 = 4;
|
mr-686: Phase 2 — op-kind-aware version check + coord Mutex → RwLock
Fix A: op-kind-aware ensure_expected_version. Insert/Merge skip the
strict pre-stage check; Update/Delete/SchemaRewrite keep it. New
MutationOpKind enum threaded through open_for_mutation_on_branch /
open_owned_dataset_for_branch_write / reopen_for_mutation and all
callers (execute_insert/update/delete_node/delete_edge,
branch_merge::publish_rewritten_merge_table, schema_apply,
ensure_indices_for_branch, loader Append/Merge/Overwrite). Closes the
77% rejection rate on same-key concurrent inserts.
Fix B: coordinator Mutex -> RwLock. Reads parallelize via .read();
writes serialize via .write(). Atomic-commit invariant preserved by
the single .write() covering commit_manifest_updates +
record_graph_commit.
Bench-as-test change_concurrent_inserts_same_key_serialize_without_409
(server.rs:2180) spawns 12 concurrent /change inserts on a single
(table, branch); asserts every request returns 200. Was failing
pre-Phase-2; passes post-Phase-2.
change_conflict_returns_manifest_conflict_409 (cross-process drift
sentinel) and branch_merge_conflict_response_includes_structured_conflicts
both still pass.
Bench (after-pr2-phase2):
- single-actor 1x1: 14.9 ops/s, p50 68ms (baseline 12.3, +22%)
- disjoint 8x8: 7.04 ops/s, p50 1023ms (baseline 6.24, +13%)
- same-key 8x1: 2.62 ops/s, 0 errors (after-pr2: 77% errors)
Disjoint stayed at +13% — Fix B's RwLock helped read paths but the
publisher's .write() critical section still serializes graph-wide.
Splitting GraphCoordinator into per-concern primitives (manifest in
ArcSwap, commit_graph in RwLock, atomic-commit serializer) is the
deferred next step.
102 lib + 30 branching + 24 runs + 16 staged_writes + 63 end_to_end
+ 40 server tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:42:26 +02:00
|
|
|
|
const N: usize = 12;
|
|
|
|
|
|
|
|
|
|
|
|
let mut handles = Vec::with_capacity(N);
|
|
|
|
|
|
for i in 0..N {
|
|
|
|
|
|
let app = app.clone();
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": format!("racer-{i}"), "age": i as i32 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
response.status()
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut statuses = Vec::with_capacity(N);
|
|
|
|
|
|
for h in handles {
|
|
|
|
|
|
statuses.push(h.await.unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let bad: Vec<_> = statuses
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, s)| **s != StatusCode::OK)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
bad.is_empty(),
|
|
|
|
|
|
"expected every concurrent insert to return 200, got non-200 for: {:?}",
|
|
|
|
|
|
bad
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-08 16:49:38 +02:00
|
|
|
|
// Verify the inserts actually landed. The status check above only proves
|
|
|
|
|
|
// the publisher CAS didn't reject; the row count proves none of the
|
|
|
|
|
|
// concurrent commits silently overwrote a peer.
|
|
|
|
|
|
let (snapshot_status, snapshot_body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(snapshot_status, StatusCode::OK);
|
|
|
|
|
|
let person_rows = snapshot_body["tables"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.and_then(|tables| {
|
|
|
|
|
|
tables
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|t| t["table_key"].as_str() == Some("node:Person"))
|
|
|
|
|
|
})
|
|
|
|
|
|
.and_then(|t| t["row_count"].as_u64())
|
|
|
|
|
|
.expect("snapshot must include node:Person row_count");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
person_rows,
|
|
|
|
|
|
SEED_PERSON_ROWS + N as u64,
|
|
|
|
|
|
"expected {} seeded + {} concurrent inserts = {} Person rows; got {}",
|
|
|
|
|
|
SEED_PERSON_ROWS,
|
|
|
|
|
|
N,
|
|
|
|
|
|
SEED_PERSON_ROWS + N as u64,
|
|
|
|
|
|
person_rows,
|
|
|
|
|
|
);
|
mr-686: Phase 2 — op-kind-aware version check + coord Mutex → RwLock
Fix A: op-kind-aware ensure_expected_version. Insert/Merge skip the
strict pre-stage check; Update/Delete/SchemaRewrite keep it. New
MutationOpKind enum threaded through open_for_mutation_on_branch /
open_owned_dataset_for_branch_write / reopen_for_mutation and all
callers (execute_insert/update/delete_node/delete_edge,
branch_merge::publish_rewritten_merge_table, schema_apply,
ensure_indices_for_branch, loader Append/Merge/Overwrite). Closes the
77% rejection rate on same-key concurrent inserts.
Fix B: coordinator Mutex -> RwLock. Reads parallelize via .read();
writes serialize via .write(). Atomic-commit invariant preserved by
the single .write() covering commit_manifest_updates +
record_graph_commit.
Bench-as-test change_concurrent_inserts_same_key_serialize_without_409
(server.rs:2180) spawns 12 concurrent /change inserts on a single
(table, branch); asserts every request returns 200. Was failing
pre-Phase-2; passes post-Phase-2.
change_conflict_returns_manifest_conflict_409 (cross-process drift
sentinel) and branch_merge_conflict_response_includes_structured_conflicts
both still pass.
Bench (after-pr2-phase2):
- single-actor 1x1: 14.9 ops/s, p50 68ms (baseline 12.3, +22%)
- disjoint 8x8: 7.04 ops/s, p50 1023ms (baseline 6.24, +13%)
- same-key 8x1: 2.62 ops/s, 0 errors (after-pr2: 77% errors)
Disjoint stayed at +13% — Fix B's RwLock helped read paths but the
publisher's .write() critical section still serializes graph-wide.
Splitting GraphCoordinator into per-concern primitives (manifest in
ArcSwap, commit_graph in RwLock, atomic-commit serializer) is the
deferred next step.
102 lib + 30 branching + 24 runs + 16 staged_writes + 63 end_to_end
+ 40 server tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:42:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 16:33:53 +02:00
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
|
async fn change_concurrent_updates_same_key_serialize_via_publisher_cas() {
|
|
|
|
|
|
// Pin Update RYW semantics under in-process concurrency on the same
|
|
|
|
|
|
// `(table, branch)`. With per-table queue serialization and op-kind-aware
|
|
|
|
|
|
// drift detection at commit time, exactly one of N concurrent UPDATEs
|
|
|
|
|
|
// on the same row commits; the rest are rejected as 409 manifest_conflict.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Pre-fix bug class: in `MutationStaging::commit_all`, after queue
|
|
|
|
|
|
// acquisition, the staged Lance transaction is handed straight to
|
|
|
|
|
|
// `commit_staged`. For a writer whose staged dataset is at V0 but
|
|
|
|
|
|
// Lance HEAD has advanced to V1 (because the queue's prior winner
|
|
|
|
|
|
// already published), Lance's transaction conflict resolver fires
|
|
|
|
|
|
// `RetryableCommitConflict` on Update vs Update on the same row.
|
|
|
|
|
|
// That error gets wrapped as `OmniError::Lance(<string>)` and the
|
|
|
|
|
|
// API surfaces it as **500 internal**, not 409. Users see "internal
|
|
|
|
|
|
// server error" instead of a retryable conflict, breaking the
|
|
|
|
|
|
// documented 409 contract for in-process drift.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Post-fix invariant: `commit_all` does an op-kind-aware drift check
|
|
|
|
|
|
// before each `commit_staged`. For tables whose tracked op_kind has
|
|
|
|
|
|
// `strict_pre_stage_version_check() == true` (Update / Delete /
|
|
|
|
|
|
// SchemaRewrite), if the staged dataset's version doesn't match the
|
|
|
|
|
|
// fresh manifest pin, return `OmniError::manifest_expected_version_mismatch`
|
|
|
|
|
|
// → 409 ExpectedVersionMismatch. The N-1 losers see a clean 409
|
|
|
|
|
|
// before Lance's commit_staged ever runs.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Why correct-by-design: closing the class "Lance internal conflict
|
|
|
|
|
|
// surfaces as 500 instead of 409" rather than mapping the specific
|
|
|
|
|
|
// Lance error variant. The drift check fires at the right architectural
|
|
|
|
|
|
// layer (engine boundary, under the queue) and respects the existing
|
|
|
|
|
|
// `MutationOpKind` policy.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-05-08 16:33:53 +02:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
// Spawn N=8 concurrent UPDATEs on Alice (from test.jsonl, age=30 at V0)
|
|
|
|
|
|
// writing distinct ages.
|
|
|
|
|
|
const N: usize = 8;
|
|
|
|
|
|
let mut handles = Vec::with_capacity(N);
|
|
|
|
|
|
for i in 0..N {
|
|
|
|
|
|
let app = app.clone();
|
|
|
|
|
|
let target_age = 100 + i as i32;
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("set_age".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "Alice", "age": target_age })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
(status, body.to_vec())
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut results = Vec::with_capacity(N);
|
|
|
|
|
|
for h in handles {
|
|
|
|
|
|
results.push(h.await.unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
let statuses: Vec<StatusCode> = results.iter().map(|(s, _)| *s).collect();
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let ok_count = statuses.iter().filter(|s| **s == StatusCode::OK).count();
|
2026-05-08 16:33:53 +02:00
|
|
|
|
let conflict_count = statuses
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|s| **s == StatusCode::CONFLICT)
|
|
|
|
|
|
.count();
|
|
|
|
|
|
let other: Vec<_> = statuses
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, s)| **s != StatusCode::OK && **s != StatusCode::CONFLICT)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
let other_bodies: Vec<(usize, StatusCode, String)> = other
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|(i, s)| {
|
|
|
|
|
|
let body_str = String::from_utf8_lossy(&results[*i].1).to_string();
|
|
|
|
|
|
(*i, **s, body_str)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
other.is_empty(),
|
|
|
|
|
|
"expected only 200 or 409 statuses, got non-200/409 entries: {:?}",
|
|
|
|
|
|
other_bodies
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
ok_count + conflict_count,
|
|
|
|
|
|
N,
|
|
|
|
|
|
"all responses must be 200 or 409 to satisfy the RYW invariant; statuses: {:?}",
|
|
|
|
|
|
statuses
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
ok_count,
|
|
|
|
|
|
1,
|
2026-05-08 16:33:53 +02:00
|
|
|
|
"expected exactly one update to commit and N-1 to receive 409 manifest_conflict \
|
|
|
|
|
|
(op-kind-aware drift check rejects stale-V0 staged datasets at commit_all entry). \
|
|
|
|
|
|
Got {} OK + {} 409 + {} other. \
|
|
|
|
|
|
Pre-fix symptom: 1 OK + (N-1) x 500 because Lance's RetryableCommitConflict for \
|
|
|
|
|
|
Update vs Update on the same row bubbles up as `OmniError::Lance(<string>)` and \
|
|
|
|
|
|
the API maps it to 500 internal, not 409. Statuses: {:?}",
|
|
|
|
|
|
ok_count,
|
|
|
|
|
|
conflict_count,
|
|
|
|
|
|
statuses.len() - ok_count - conflict_count,
|
|
|
|
|
|
statuses,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Branch-ops morphological matrix
|
|
|
|
|
|
//
|
|
|
|
|
|
// Table-driven test covering all interesting (op_a, op_b, target_overlap)
|
|
|
|
|
|
// concurrent-pair cells with the C1-C6 invariants asserted uniformly:
|
|
|
|
|
|
//
|
|
|
|
|
|
// C1 — both complete (no deadlock, no hang)
|
|
|
|
|
|
// C2 — status: both 200, or exactly one clean conflict (409/429), no 500
|
|
|
|
|
|
// C3 — per-target row count
|
|
|
|
|
|
// C4 — per-target row identity (present + absent named persons)
|
|
|
|
|
|
// C5 — engine state remains coherent (subsequent /snapshot is consistent)
|
|
|
|
|
|
// C6 — post-op /change on main succeeds (engine state isn't poisoned)
|
|
|
|
|
|
//
|
|
|
|
|
|
// Cell list (a-k) below. Each cell uses a fresh tempdir + AppState so a
|
|
|
|
|
|
// failure in one doesn't leak into the next. Within a cell, ops align at
|
|
|
|
|
|
// a tokio::sync::Barrier so both reach the engine close in time, and the
|
|
|
|
|
|
// pair is wrapped in tokio::time::timeout(15s) so a deadlock surfaces
|
|
|
|
|
|
// as a clean panic.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Replaces the three narrow concurrent_branch_* tests below; their
|
|
|
|
|
|
// scenarios are folded into cells f, h, i (branch_create_from race),
|
|
|
|
|
|
// cell a (merge race with C4 identity assertions), and cell d
|
|
|
|
|
|
// (concurrent change-during-merge).
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
mod matrix {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
use tokio::sync::Barrier;
|
|
|
|
|
|
|
2026-05-09 20:16:12 +00:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub(super) struct OpStatus {
|
|
|
|
|
|
pub status: StatusCode,
|
|
|
|
|
|
pub body: Vec<u8>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
pub(super) struct Harness {
|
|
|
|
|
|
pub _temp: tempfile::TempDir,
|
|
|
|
|
|
pub app: Router,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Harness {
|
|
|
|
|
|
pub async fn new() -> Self {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
2026-05-08 20:19:42 +02:00
|
|
|
|
// Build the WorkloadController explicitly with defaults rather
|
|
|
|
|
|
// than letting `AppState::open` call
|
|
|
|
|
|
// `WorkloadController::from_env()`. The admission-gate test
|
|
|
|
|
|
// (`ingest_per_actor_admission_cap_returns_429`) sets
|
|
|
|
|
|
// OMNIGRAPH_PER_ACTOR_INFLIGHT_MAX=1 inside an EnvGuard while
|
|
|
|
|
|
// it runs. Process-wide env vars are visible to
|
|
|
|
|
|
// concurrently-running tests; if a matrix cell reads env at
|
|
|
|
|
|
// AppState construction time during that window it picks up
|
|
|
|
|
|
// cap=1 and the second concurrent merge in cell b surfaces
|
|
|
|
|
|
// 429 instead of the expected 200. Constructing the
|
|
|
|
|
|
// controller here with explicit defaults makes cells
|
|
|
|
|
|
// independent of any env mutation other tests perform.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
|
|
|
|
let workload = omnigraph_server::workload::WorkloadController::with_defaults();
|
2026-05-08 20:19:42 +02:00
|
|
|
|
let state = AppState::new_with_workload(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-05-08 20:19:42 +02:00
|
|
|
|
db,
|
|
|
|
|
|
Vec::new(),
|
|
|
|
|
|
workload,
|
|
|
|
|
|
);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
let app = build_app(state);
|
2026-05-24 16:46:00 +01:00
|
|
|
|
Self { _temp: temp, app }
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn create_branch(&self, from: &str, name: &str) {
|
|
|
|
|
|
let body = serde_json::to_vec(&BranchCreateRequest {
|
|
|
|
|
|
from: Some(from.to_string()),
|
|
|
|
|
|
name: name.to_string(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let r = self
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
r.status(),
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
"setup create_branch {} from {} failed",
|
|
|
|
|
|
name,
|
|
|
|
|
|
from
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn insert_person(&self, branch: &str, name: &str, age: i32) {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": name, "age": age })),
|
|
|
|
|
|
branch: Some(branch.to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let r = self
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
r.status(),
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
"setup insert {} on {} failed",
|
|
|
|
|
|
name,
|
|
|
|
|
|
branch
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Run two ops concurrently with barrier alignment + 15s deadlock
|
2026-05-09 20:16:12 +00:00
|
|
|
|
/// timeout. Returns `(op_a, op_b)`. Panics on timeout.
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
pub async fn run_pair(
|
|
|
|
|
|
&self,
|
2026-05-09 20:16:12 +00:00
|
|
|
|
op_a: impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus>,
|
|
|
|
|
|
op_b: impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus>,
|
|
|
|
|
|
) -> (OpStatus, OpStatus) {
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
|
|
|
|
let h_a = op_a(self.app.clone(), Arc::clone(&barrier));
|
|
|
|
|
|
let h_b = op_b(self.app.clone(), Arc::clone(&barrier));
|
|
|
|
|
|
let result = tokio::time::timeout(Duration::from_secs(15), async {
|
|
|
|
|
|
let a = h_a.await.unwrap();
|
|
|
|
|
|
let b = h_b.await.unwrap();
|
|
|
|
|
|
(a, b)
|
|
|
|
|
|
})
|
|
|
|
|
|
.await;
|
|
|
|
|
|
result.expect("concurrent op pair deadlocked (>15s)")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub async fn person_count(&self, branch: &str) -> u64 {
|
|
|
|
|
|
let r = self
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!("/snapshot?branch={}", branch))
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
assert_eq!(r.status(), StatusCode::OK, "snapshot {} failed", branch);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
|
|
v["tables"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.and_then(|tables| {
|
|
|
|
|
|
tables
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|t| t["table_key"].as_str() == Some("node:Person"))
|
|
|
|
|
|
})
|
|
|
|
|
|
.and_then(|t| t["row_count"].as_u64())
|
|
|
|
|
|
.unwrap_or_else(|| panic!("snapshot {} missing node:Person", branch))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// True iff the named Person exists on `branch`. Uses the
|
|
|
|
|
|
/// `get_person` query from `test.gq` for identity rather than
|
|
|
|
|
|
/// just count.
|
|
|
|
|
|
pub async fn person_exists(&self, branch: &str, name: &str) -> bool {
|
|
|
|
|
|
let body = serde_json::to_vec(&ReadRequest {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
query_source: include_str!("../../omnigraph/tests/fixtures/test.gq").to_string(),
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
query_name: Some("get_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": name })),
|
|
|
|
|
|
branch: Some(branch.to_string()),
|
|
|
|
|
|
snapshot: None,
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let r = self
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
r.status(),
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
"person_exists query for {} on {} failed",
|
|
|
|
|
|
name,
|
|
|
|
|
|
branch
|
|
|
|
|
|
);
|
|
|
|
|
|
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
|
|
v["row_count"].as_u64().unwrap_or(0) > 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Asserts each name in `present` exists on `branch` and each in
|
|
|
|
|
|
/// `absent` does not. Identity-grade check that catches symmetric
|
|
|
|
|
|
/// swap races a row-count assertion would miss.
|
|
|
|
|
|
pub async fn assert_persons(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
branch: &str,
|
|
|
|
|
|
cell: &str,
|
|
|
|
|
|
present: &[&str],
|
|
|
|
|
|
absent: &[&str],
|
|
|
|
|
|
) {
|
|
|
|
|
|
for name in present {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
self.person_exists(branch, name).await,
|
|
|
|
|
|
"[{}] expected {} to be present on {}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
name,
|
|
|
|
|
|
branch
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
for name in absent {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!self.person_exists(branch, name).await,
|
|
|
|
|
|
"[{}] expected {} to be absent from {}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
name,
|
|
|
|
|
|
branch
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// C6: insert a uniquely-named sentinel on main and verify it
|
|
|
|
|
|
/// landed. Catches engine-state poisoning where a cell's
|
|
|
|
|
|
/// concurrent ops left the engine half-broken — subsequent
|
|
|
|
|
|
/// /change either deadlocks or returns a non-200.
|
|
|
|
|
|
pub async fn assert_post_op_sentinel(&self, cell: &str, sentinel: &str) {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": sentinel, "age": 99 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let r = self
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
r.status(),
|
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
|
"[{}] post-op sentinel /change on main failed (engine poisoned?)",
|
|
|
|
|
|
cell
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
self.person_exists("main", sentinel).await,
|
|
|
|
|
|
"[{}] sentinel {} did not land on main",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
sentinel
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Helpers that build the closures for `run_pair`. Each takes a
|
2026-05-09 20:16:12 +00:00
|
|
|
|
// Router + Barrier and returns a JoinHandle yielding the status/body.
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
|
|
|
|
|
|
pub(super) fn op_merge(
|
|
|
|
|
|
source: String,
|
|
|
|
|
|
target: String,
|
2026-05-09 20:16:12 +00:00
|
|
|
|
) -> impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus> {
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
move |app: Router, barrier: Arc<Barrier>| {
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
|
barrier.wait().await;
|
|
|
|
|
|
let body = serde_json::to_vec(&BranchMergeRequest {
|
|
|
|
|
|
source,
|
|
|
|
|
|
target: Some(target),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
2026-05-09 20:16:12 +00:00
|
|
|
|
let response = app
|
|
|
|
|
|
.oneshot(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
2026-05-09 20:16:12 +00:00
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
OpStatus {
|
|
|
|
|
|
status,
|
|
|
|
|
|
body: body.to_vec(),
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn op_change_insert(
|
|
|
|
|
|
branch: String,
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
age: i32,
|
2026-05-09 20:16:12 +00:00
|
|
|
|
) -> impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus> {
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
move |app: Router, barrier: Arc<Barrier>| {
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
|
barrier.wait().await;
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": name, "age": age })),
|
|
|
|
|
|
branch: Some(branch),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
2026-05-09 20:16:12 +00:00
|
|
|
|
let response = app
|
|
|
|
|
|
.oneshot(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
2026-05-09 20:16:12 +00:00
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
OpStatus {
|
|
|
|
|
|
status,
|
|
|
|
|
|
body: body.to_vec(),
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn op_branch_create(
|
|
|
|
|
|
from: String,
|
|
|
|
|
|
name: String,
|
2026-05-09 20:16:12 +00:00
|
|
|
|
) -> impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus> {
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
move |app: Router, barrier: Arc<Barrier>| {
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
|
barrier.wait().await;
|
|
|
|
|
|
let body = serde_json::to_vec(&BranchCreateRequest {
|
|
|
|
|
|
from: Some(from),
|
|
|
|
|
|
name,
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
2026-05-09 20:16:12 +00:00
|
|
|
|
let response = app
|
|
|
|
|
|
.oneshot(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap(),
|
2026-05-09 20:16:12 +00:00
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
OpStatus {
|
|
|
|
|
|
status,
|
|
|
|
|
|
body: body.to_vec(),
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn op_branch_delete(
|
|
|
|
|
|
name: String,
|
2026-05-09 20:16:12 +00:00
|
|
|
|
) -> impl FnOnce(Router, Arc<Barrier>) -> tokio::task::JoinHandle<OpStatus> {
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
move |app: Router, barrier: Arc<Barrier>| {
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
|
barrier.wait().await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
let response = app
|
|
|
|
|
|
.oneshot(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri(format!("/branches/{}", name))
|
|
|
|
|
|
.method(Method::DELETE)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
2026-05-09 20:16:12 +00:00
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
OpStatus {
|
|
|
|
|
|
status,
|
|
|
|
|
|
body: body.to_vec(),
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
|
async fn concurrent_branch_ops_morphological_matrix() {
|
|
|
|
|
|
// Cell a: Merge × Merge, distinct targets.
|
|
|
|
|
|
// Pre-fix on b09a097/22d76db: branch_merge_impl's swap-restore race
|
|
|
|
|
|
// landed feature_a's content in target_b instead of target_a (and
|
|
|
|
|
|
// vice versa — symmetric swap). Identity asserts catch both
|
|
|
|
|
|
// asymmetric and symmetric variants.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "a:merge×merge:distinct-targets";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "feature-a-cella").await;
|
|
|
|
|
|
h.insert_person("feature-a-cella", "EveA-cella", 22).await;
|
|
|
|
|
|
h.create_branch("main", "feature-b-cella").await;
|
|
|
|
|
|
h.insert_person("feature-b-cella", "FrankB-cella", 33).await;
|
|
|
|
|
|
h.create_branch("main", "target-a-cella").await;
|
|
|
|
|
|
h.create_branch("main", "target-b-cella").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
matrix::op_merge("feature-a-cella".to_string(), "target-a-cella".to_string()),
|
|
|
|
|
|
matrix::op_merge("feature-b-cella".to_string(), "target-b-cella".to_string()),
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] merge a", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] merge b", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_persons("target-a-cella", cell, &["EveA-cella"], &["FrankB-cella"])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
h.assert_persons("target-b-cella", cell, &["FrankB-cella"], &["EveA-cella"])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cella").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell b: Merge × Merge, same target / distinct sources.
|
|
|
|
|
|
// Both want to land in main. merge_exclusive serializes; both should
|
|
|
|
|
|
// succeed and main should contain BOTH sources' contributions.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "b:merge×merge:same-target-distinct-sources";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "src-x-cellb").await;
|
|
|
|
|
|
h.insert_person("src-x-cellb", "Xavier-cellb", 41).await;
|
|
|
|
|
|
h.create_branch("main", "src-y-cellb").await;
|
|
|
|
|
|
h.insert_person("src-y-cellb", "Yvonne-cellb", 42).await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_merge("src-x-cellb".to_string(), "main".to_string()),
|
|
|
|
|
|
matrix::op_merge("src-y-cellb".to_string(), "main".to_string()),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] merge x", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] merge y", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_persons("main", cell, &["Xavier-cellb", "Yvonne-cellb"], &[])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellb").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell c: Merge × Merge, same source / distinct targets (fanout).
|
|
|
|
|
|
// One source merged into two targets simultaneously. merge_exclusive
|
|
|
|
|
|
// serializes; both targets should reflect the source's content.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "c:merge×merge:same-source-distinct-targets";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "src-shared-cellc").await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
h.insert_person("src-shared-cellc", "Sharon-cellc", 50)
|
|
|
|
|
|
.await;
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.create_branch("main", "tgt-1-cellc").await;
|
|
|
|
|
|
h.create_branch("main", "tgt-2-cellc").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
matrix::op_merge("src-shared-cellc".to_string(), "tgt-1-cellc".to_string()),
|
|
|
|
|
|
matrix::op_merge("src-shared-cellc".to_string(), "tgt-2-cellc".to_string()),
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] merge into tgt-1", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] merge into tgt-2", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_persons("tgt-1-cellc", cell, &["Sharon-cellc"], &[])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
h.assert_persons("tgt-2-cellc", cell, &["Sharon-cellc"], &[])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellc").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 20:16:12 +00:00
|
|
|
|
// Cell d: Merge × Change, both touching main. C2 permits both
|
|
|
|
|
|
// succeed, or exactly one clean 409 if the merge detects target
|
|
|
|
|
|
// movement after planning but before acquiring the queue.
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
{
|
|
|
|
|
|
let cell = "d:merge×change:into-target";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "feature-celld").await;
|
|
|
|
|
|
h.insert_person("feature-celld", "EveD-celld", 22).await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_merge("feature-celld".to_string(), "main".to_string()),
|
|
|
|
|
|
matrix::op_change_insert("main".to_string(), "FrankD-celld".to_string(), 33),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
sa.status == StatusCode::OK || sa.status == StatusCode::CONFLICT,
|
|
|
|
|
|
"[{}] merge must be 200 or clean 409, got {}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
sa.status
|
|
|
|
|
|
);
|
|
|
|
|
|
if sa.status == StatusCode::OK {
|
|
|
|
|
|
h.assert_persons("main", cell, &["EveD-celld", "FrankD-celld"], &[])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_slice(&sa.body).unwrap();
|
|
|
|
|
|
let conflict = error
|
|
|
|
|
|
.manifest_conflict
|
|
|
|
|
|
.expect("merge 409 must include manifest_conflict");
|
2026-05-24 16:46:00 +01:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
conflict.table_key, "node:Person",
|
|
|
|
|
|
"[{}] conflict table",
|
|
|
|
|
|
cell
|
|
|
|
|
|
);
|
2026-05-09 20:16:12 +00:00
|
|
|
|
h.assert_persons("main", cell, &["FrankD-celld"], &["EveD-celld"])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-celld").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell e: Merge × BranchCreateFrom-target. Concurrent fork off the
|
|
|
|
|
|
// merge target while the merge runs. Both should succeed; the new
|
|
|
|
|
|
// branch should have a coherent view (either pre- or post-merge,
|
|
|
|
|
|
// both valid). After both, target = main has the merged content.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "e:merge×branch_create_from:target";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "src-celle").await;
|
|
|
|
|
|
h.insert_person("src-celle", "Eve-celle", 22).await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_merge("src-celle".to_string(), "main".to_string()),
|
|
|
|
|
|
matrix::op_branch_create("main".to_string(), "fork-celle".to_string()),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] merge", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] branch_create_from", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// Main definitely has Eve.
|
|
|
|
|
|
h.assert_persons("main", cell, &["Eve-celle"], &[]).await;
|
|
|
|
|
|
// fork-celle was forked off main at SOME version; main's current
|
|
|
|
|
|
// count is 5 (4 seeded + Eve). fork-celle has either 4 (pre-merge
|
|
|
|
|
|
// snapshot) or 5 (post-merge snapshot); both are valid timings.
|
|
|
|
|
|
let fork_count = h.person_count("fork-celle").await;
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
fork_count == 4 || fork_count == 5,
|
|
|
|
|
|
"[{}] fork-celle row count must be pre- or post-merge view (4 or 5), got {}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
fork_count
|
|
|
|
|
|
);
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-celle").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell f: BranchCreateFrom × BranchCreateFrom, distinct parents.
|
|
|
|
|
|
// Pre-fix on f925ad1: swap-restore race in branch_create_from_impl
|
|
|
|
|
|
// forked the new branch off the wrong parent. Identity asserts pin
|
|
|
|
|
|
// that fork-from-A inherits A's content, fork-from-B inherits B's.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "f:branch_create_from×branch_create_from:distinct-parents";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "alpha-cellf").await;
|
|
|
|
|
|
h.insert_person("alpha-cellf", "Eve-cellf", 22).await;
|
|
|
|
|
|
h.create_branch("main", "beta-cellf").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
matrix::op_branch_create("alpha-cellf".to_string(), "gamma-cellf".to_string()),
|
|
|
|
|
|
matrix::op_branch_create("beta-cellf".to_string(), "delta-cellf".to_string()),
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] gamma create", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] delta create", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// gamma forks off alpha → must contain Eve.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
h.assert_persons("gamma-cellf", cell, &["Eve-cellf"], &[])
|
|
|
|
|
|
.await;
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// delta forks off beta → must NOT contain Eve.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
h.assert_persons("delta-cellf", cell, &[], &["Eve-cellf"])
|
|
|
|
|
|
.await;
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellf").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell g: BranchCreateFrom × BranchDelete, unrelated branches.
|
|
|
|
|
|
// Disjoint branches; both should complete cleanly without
|
|
|
|
|
|
// interference.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "g:branch_create_from×branch_delete:unrelated";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "doomed-cellg").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_branch_create("main".to_string(), "newborn-cellg".to_string()),
|
|
|
|
|
|
matrix::op_branch_delete("doomed-cellg".to_string()),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] create newborn", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] delete doomed", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// newborn-cellg exists with main's content.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
h.assert_persons("newborn-cellg", cell, &["Alice"], &[])
|
|
|
|
|
|
.await;
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellg").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell h: BranchDelete × BranchDelete, distinct branches. Both call
|
|
|
|
|
|
// refresh() internally; verify no deadlock and both deletes land.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "h:branch_delete×branch_delete:distinct";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "doomed1-cellh").await;
|
|
|
|
|
|
h.create_branch("main", "doomed2-cellh").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_branch_delete("doomed1-cellh".to_string()),
|
|
|
|
|
|
matrix::op_branch_delete("doomed2-cellh".to_string()),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] delete 1", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] delete 2", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
// Verify both gone via /branches list (snapshot would still work
|
|
|
|
|
|
// for a deleted branch via parent fallback in some paths, so we
|
|
|
|
|
|
// use the explicit list).
|
|
|
|
|
|
let r = h
|
|
|
|
|
|
.app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(r.status(), StatusCode::OK);
|
|
|
|
|
|
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let list_body: Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
|
|
let branches: Vec<&str> = list_body["branches"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter_map(|v| v.as_str())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!branches.contains(&"doomed1-cellh"),
|
|
|
|
|
|
"[{}] doomed1 still in branch list: {:?}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
branches
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!branches.contains(&"doomed2-cellh"),
|
|
|
|
|
|
"[{}] doomed2 still in branch list: {:?}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
branches
|
|
|
|
|
|
);
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellh").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell i: BranchDelete × Change, on a different branch. Delete one
|
|
|
|
|
|
// branch while a /change runs on main. Both should succeed.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "i:branch_delete×change:distinct-branch";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "doomed-celli").await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_branch_delete("doomed-celli".to_string()),
|
|
|
|
|
|
matrix::op_change_insert("main".to_string(), "Pat-celli".to_string(), 44),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] delete", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_persons("main", cell, &["Pat-celli"], &[]).await;
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-celli").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell j: BranchCreateFrom × Change, both on main. The fork timing
|
|
|
|
|
|
// determines whether the new branch sees the change (pre or post).
|
|
|
|
|
|
// Both valid. Main must contain the inserted row.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "j:branch_create_from×change:on-source";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_branch_create("main".to_string(), "twin-cellj".to_string()),
|
|
|
|
|
|
matrix::op_change_insert("main".to_string(), "Quincy-cellj".to_string(), 55),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sa.status, StatusCode::OK, "[{}] branch_create", cell);
|
|
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
h.assert_persons("main", cell, &["Quincy-cellj"], &[]).await;
|
|
|
|
|
|
// twin-cellj has either pre-change view (no Quincy) or
|
|
|
|
|
|
// post-change view (with Quincy); either is valid.
|
|
|
|
|
|
let twin_has_quincy = h.person_exists("twin-cellj", "Quincy-cellj").await;
|
|
|
|
|
|
let _ = twin_has_quincy; // either valid timing — just ensure no panic
|
|
|
|
|
|
h.assert_post_op_sentinel(cell, "sentinel-cellj").await;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cell k: reopen consistency. Run a representative concurrent pair,
|
|
|
|
|
|
// drop the engine, reopen on a separate handle, verify state matches.
|
|
|
|
|
|
{
|
|
|
|
|
|
let cell = "k:reopen-after-pair";
|
|
|
|
|
|
let h = matrix::Harness::new().await;
|
|
|
|
|
|
h.create_branch("main", "src-cellk").await;
|
|
|
|
|
|
h.insert_person("src-cellk", "Rita-cellk", 36).await;
|
|
|
|
|
|
|
|
|
|
|
|
let (sa, sb) = h
|
|
|
|
|
|
.run_pair(
|
|
|
|
|
|
matrix::op_merge("src-cellk".to_string(), "main".to_string()),
|
|
|
|
|
|
matrix::op_change_insert("main".to_string(), "Steve-cellk".to_string(), 37),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-09 20:16:12 +00:00
|
|
|
|
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
2026-05-09 20:16:44 +00:00
|
|
|
|
assert!(
|
|
|
|
|
|
sa.status == StatusCode::OK || sa.status == StatusCode::CONFLICT,
|
|
|
|
|
|
"[{}] merge must be 200 or clean 409, got {}",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
sa.status
|
|
|
|
|
|
);
|
|
|
|
|
|
if sa.status == StatusCode::OK {
|
|
|
|
|
|
h.assert_persons("main", cell, &["Rita-cellk", "Steve-cellk"], &[])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_slice(&sa.body).unwrap();
|
|
|
|
|
|
let conflict = error
|
|
|
|
|
|
.manifest_conflict
|
|
|
|
|
|
.expect("merge 409 must include manifest_conflict");
|
2026-05-24 16:46:00 +01:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
conflict.table_key, "node:Person",
|
|
|
|
|
|
"[{}] conflict table",
|
|
|
|
|
|
cell
|
|
|
|
|
|
);
|
2026-05-09 20:16:44 +00:00
|
|
|
|
h.assert_persons("main", cell, &["Steve-cellk"], &["Rita-cellk"])
|
|
|
|
|
|
.await;
|
|
|
|
|
|
}
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
// Reopen via a fresh AppState on the same graph.
|
|
|
|
|
|
let graph_uri = format!("{}/server.omni", h._temp.path().display());
|
|
|
|
|
|
let reopened = AppState::open(graph_uri.clone()).await.unwrap();
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
let app2 = build_app(reopened);
|
|
|
|
|
|
// Sanity: the same identity check via the new app must see
|
|
|
|
|
|
// Rita and Steve.
|
|
|
|
|
|
let r = app2
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(r.status(), StatusCode::OK, "[{}] reopen snapshot", cell);
|
2026-05-09 20:16:44 +00:00
|
|
|
|
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
|
|
let person_rows = v["tables"]
|
|
|
|
|
|
.as_array()
|
|
|
|
|
|
.and_then(|tables| {
|
|
|
|
|
|
tables
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|t| t["table_key"].as_str() == Some("node:Person"))
|
|
|
|
|
|
})
|
|
|
|
|
|
.and_then(|t| t["row_count"].as_u64())
|
|
|
|
|
|
.expect("reopen snapshot must include node:Person row_count");
|
|
|
|
|
|
let expected_rows = if sa.status == StatusCode::OK { 6 } else { 5 };
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
person_rows, expected_rows,
|
|
|
|
|
|
"[{}] reopened main should include seed (4) + committed concurrent writes",
|
|
|
|
|
|
cell,
|
|
|
|
|
|
);
|
tests: branch-ops morphological matrix (T1)
Replaces three narrow concurrent_branch_* tests (folded in below) with
one parameterized matrix test covering 11 representative
(op_a, op_b, target_overlap) cells, asserting C1-C6 uniformly:
C1 — both complete (no deadlock; tokio::time::timeout(15s))
C2 — status: both 200 or exactly one clean conflict; never 500
C3 — per-target row count
C4 — per-target row identity (named persons present + absent — catches
the symmetric-swap class that count assertions miss; cubic P2 on
commit 64f2b99 flagged this gap on the round-3 merge race test)
C5 — engine state coherent (subsequent /snapshot consistent)
C6 — post-op /change on main succeeds (engine isn't poisoned)
Cells:
a. Merge × Merge, distinct targets — branch_merge_impl race pin
b. Merge × Merge, same target / distinct sources — merge_exclusive serialization
c. Merge × Merge, same source / distinct targets — fanout
d. Merge × Change, into target — per-(table, branch) queue
e. Merge × BranchCreateFrom, target — interaction with refresh path
f. BranchCreateFrom × BranchCreateFrom, distinct parents — round-1 race pin
g. BranchCreateFrom × BranchDelete, unrelated branches — disjoint state
h. BranchDelete × BranchDelete, distinct branches — concurrent refresh
i. BranchDelete × Change, distinct branch — refresh-side vs writer
j. BranchCreateFrom × Change, on source — fork-while-writing
k. Reopen consistency after concurrent pair — disk-vs-cache drift
Each cell:
- spins up its own tempdir + AppState so failures don't cascade,
- aligns the pair at a tokio::sync::Barrier so both reach the engine
close in time,
- wraps in a 15s deadlock timeout,
- asserts identity via a /read with the `get_person` fixture query
(specific names must be present on the right branch and absent from
the wrong one).
Subsumes:
- concurrent_branch_create_from_distinct_parents_does_not_corrupt_coordinator
(now cell f, with identity assertions added)
- concurrent_branch_merges_distinct_targets_do_not_swap_into_each_other
(now cells a + b + c, with identity assertions; the symmetric-swap
blind spot cubic flagged on commit 64f2b99 is closed)
- concurrent_change_during_branch_merge_preserves_writes
(now cell d)
Those three narrow tests are removed in the next commit so this lands
green standalone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:07:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 17:01:52 +02:00
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
|
async fn change_disjoint_table_concurrency_succeeds_at_http_level() {
|
|
|
|
|
|
// HTTP-level pin for MR-686's disjoint-table promise: concurrent /change
|
|
|
|
|
|
// requests touching different node types must coexist without admission
|
|
|
|
|
|
// rejection or publisher-CAS conflict. The bench harness measures
|
|
|
|
|
|
// throughput; this test is the regression sentinel that catches a
|
|
|
|
|
|
// future change which accidentally re-introduces graph-wide
|
|
|
|
|
|
// serialization on the disjoint path.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Setup: test.jsonl seeds 4 Persons + 2 Companies. Spawn N=4 concurrent
|
|
|
|
|
|
// /change inserts on `node:Person` and N=4 concurrent inserts on
|
|
|
|
|
|
// `node:Company`. All 8 must return 200, and the post-test row counts
|
|
|
|
|
|
// must reflect every insert.
|
|
|
|
|
|
const PERSON_QUERY: &str = r#"
|
|
|
|
|
|
query insert_p($name: String, $age: I32) {
|
|
|
|
|
|
insert Person { name: $name, age: $age }
|
|
|
|
|
|
}
|
|
|
|
|
|
"#;
|
|
|
|
|
|
const COMPANY_QUERY: &str = r#"
|
|
|
|
|
|
query insert_c($name: String) {
|
|
|
|
|
|
insert Company { name: $name }
|
|
|
|
|
|
}
|
|
|
|
|
|
"#;
|
|
|
|
|
|
const SEED_PERSONS: u64 = 4;
|
|
|
|
|
|
const SEED_COMPANIES: u64 = 2;
|
|
|
|
|
|
const PER_TYPE: usize = 4;
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
2026-05-08 17:01:52 +02:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
|
|
|
|
|
|
let mut handles = Vec::with_capacity(PER_TYPE * 2);
|
|
|
|
|
|
for i in 0..PER_TYPE {
|
|
|
|
|
|
let app_p = app.clone();
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: PERSON_QUERY.to_string(),
|
|
|
|
|
|
query_name: Some("insert_p".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": format!("p-{i}"), "age": i as i32 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
app_p.oneshot(req).await.unwrap().status()
|
|
|
|
|
|
}));
|
|
|
|
|
|
let app_c = app.clone();
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
let body = serde_json::to_vec(&ChangeRequest {
|
|
|
|
|
|
query_source: COMPANY_QUERY.to_string(),
|
|
|
|
|
|
query_name: Some("insert_c".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": format!("c-{i}") })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
app_c.oneshot(req).await.unwrap().status()
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut statuses = Vec::with_capacity(PER_TYPE * 2);
|
|
|
|
|
|
for h in handles {
|
|
|
|
|
|
statuses.push(h.await.unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let bad: Vec<_> = statuses
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, s)| **s != StatusCode::OK)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
bad.is_empty(),
|
|
|
|
|
|
"expected every disjoint /change insert to return 200, got non-200 for: {:?}",
|
|
|
|
|
|
bad,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Verify both tables landed every insert.
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot?branch=main")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
let lookup_count = |table_key: &str| -> u64 {
|
|
|
|
|
|
body["tables"]
|
|
|
|
|
|
.as_array()
|
2026-05-24 16:46:00 +01:00
|
|
|
|
.and_then(|tables| {
|
|
|
|
|
|
tables
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|t| t["table_key"].as_str() == Some(table_key))
|
|
|
|
|
|
})
|
2026-05-08 17:01:52 +02:00
|
|
|
|
.and_then(|t| t["row_count"].as_u64())
|
|
|
|
|
|
.unwrap_or_else(|| panic!("snapshot missing {}", table_key))
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
lookup_count("node:Person"),
|
|
|
|
|
|
SEED_PERSONS + PER_TYPE as u64,
|
|
|
|
|
|
"Person row count after concurrent inserts",
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
lookup_count("node:Company"),
|
|
|
|
|
|
SEED_COMPANIES + PER_TYPE as u64,
|
|
|
|
|
|
"Company row count after concurrent inserts",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 16:57:01 +02:00
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
|
|
|
|
async fn ingest_per_actor_admission_cap_returns_429() {
|
|
|
|
|
|
// Pin the admission gate on `/ingest`. With per-actor in-flight cap of 1
|
|
|
|
|
|
// and 8 concurrent requests from the same actor, at least one request
|
|
|
|
|
|
// must be rejected with HTTP 429 and `code: too_many_requests`.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Pre-fix bug class: the admission pattern at `server_change`
|
|
|
|
|
|
// (`crates/omnigraph-server/src/lib.rs:932`) was the only handler
|
|
|
|
|
|
// that called `WorkloadController::try_admit`. A heavy actor sending
|
|
|
|
|
|
// bulk-ingest traffic would exhaust shared engine capacity (Lance I/O
|
|
|
|
|
|
// threads, manifest churn) without ever hitting an admission cap.
|
|
|
|
|
|
// Pinned at the HTTP boundary so future refactors that drop the
|
|
|
|
|
|
// try_admit call from a mutating handler turn this red.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Post-fix invariant: `/ingest`, `/branches/create`, `/branches/delete`,
|
|
|
|
|
|
// `/branches/merge`, and `/schema/apply` all gate on
|
|
|
|
|
|
// `state.workload.try_admit(&actor_arc, est_bytes)` after Cedar
|
|
|
|
|
|
// authorization and before the engine call. Cap exhaustion surfaces as
|
|
|
|
|
|
// 429 with `code: too_many_requests`.
|
2026-05-08 20:35:41 +02:00
|
|
|
|
//
|
|
|
|
|
|
// Construct the WorkloadController directly with cap=1 instead of
|
|
|
|
|
|
// mutating `OMNIGRAPH_PER_ACTOR_INFLIGHT_MAX` via EnvGuard. Process-wide
|
|
|
|
|
|
// env vars are visible to concurrently-running tests; the previous
|
|
|
|
|
|
// `EnvGuard + #[serial]` pair leaked the override into any other test
|
|
|
|
|
|
// that called `AppState::open` during the guard's window
|
|
|
|
|
|
// (matrix CI failure on commit 99b0941). Using the explicit
|
|
|
|
|
|
// `AppState::new_with_workload` constructor closes that bug class —
|
|
|
|
|
|
// this test no longer mutates global state and no longer needs
|
|
|
|
|
|
// `#[serial]`.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
|
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
2026-05-08 20:35:41 +02:00
|
|
|
|
let workload = omnigraph_server::workload::WorkloadController::new(
|
|
|
|
|
|
1, // per-actor in-flight cap (the fixture under test)
|
|
|
|
|
|
1_000_000_000, // per-actor byte budget — large so it never bottlenecks
|
|
|
|
|
|
);
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
// MR-723: install a permit-all policy alongside the bearer token so
|
|
|
|
|
|
// /ingest (action=Change) passes Cedar evaluation. The test is
|
|
|
|
|
|
// exercising the admission cap, not policy — the policy is just
|
|
|
|
|
|
// enough to clear the State 3 path so the test reaches workload.
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, permit_all_policy_yaml(&["act-flooder"])).unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let policy_engine =
|
|
|
|
|
|
omnigraph_server::PolicyEngine::load(&policy_path, graph.to_string_lossy().as_ref())
|
|
|
|
|
|
.unwrap();
|
2026-05-08 20:35:41 +02:00
|
|
|
|
let state = AppState::new_with_workload(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
2026-05-08 20:35:41 +02:00
|
|
|
|
db,
|
|
|
|
|
|
vec![("act-flooder".to_string(), "flooder-token".to_string())],
|
|
|
|
|
|
workload,
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
)
|
|
|
|
|
|
.with_policy_engine(policy_engine);
|
2026-05-08 20:35:41 +02:00
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
let _temp = temp;
|
2026-05-08 16:57:01 +02:00
|
|
|
|
|
|
|
|
|
|
// Eight concurrent ingests, all from act-flooder. Only one fits in a
|
|
|
|
|
|
// cap=1 in-flight semaphore; the others must 429.
|
|
|
|
|
|
const N: usize = 8;
|
|
|
|
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(N));
|
|
|
|
|
|
let mut handles = Vec::with_capacity(N);
|
|
|
|
|
|
for i in 0..N {
|
|
|
|
|
|
let app = app.clone();
|
|
|
|
|
|
let barrier = Arc::clone(&barrier);
|
|
|
|
|
|
handles.push(tokio::spawn(async move {
|
|
|
|
|
|
// Align the 8 tasks at the barrier so they all attempt
|
|
|
|
|
|
// try_admit close in time.
|
|
|
|
|
|
barrier.wait().await;
|
|
|
|
|
|
|
|
|
|
|
|
let body = serde_json::to_vec(&IngestRequest {
|
|
|
|
|
|
data: format!(
|
|
|
|
|
|
"{{\"type\":\"Person\",\"data\":{{\"name\":\"flooder-{i}\",\"age\":{i}}}}}\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
from: Some("main".to_string()),
|
|
|
|
|
|
mode: Some(omnigraph::loader::LoadMode::Merge),
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let req = Request::builder()
|
|
|
|
|
|
.uri("/ingest")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("authorization", "Bearer flooder-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(body))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let response = app.oneshot(req).await.unwrap();
|
|
|
|
|
|
let status = response.status();
|
|
|
|
|
|
let headers = response.headers().clone();
|
|
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
|
|
|
|
|
(status, headers, body.to_vec())
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut results = Vec::with_capacity(N);
|
|
|
|
|
|
for h in handles {
|
|
|
|
|
|
results.push(h.await.unwrap());
|
|
|
|
|
|
}
|
|
|
|
|
|
let statuses: Vec<StatusCode> = results.iter().map(|(s, _, _)| *s).collect();
|
|
|
|
|
|
|
|
|
|
|
|
let too_many: Vec<usize> = statuses
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.enumerate()
|
|
|
|
|
|
.filter(|(_, s)| **s == StatusCode::TOO_MANY_REQUESTS)
|
|
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!too_many.is_empty(),
|
|
|
|
|
|
"expected at least one /ingest under cap=1 to return 429; got statuses: {:?}",
|
|
|
|
|
|
statuses,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Validate the structured error body for each 429 (body must carry
|
|
|
|
|
|
// the `too_many_requests` code so clients can distinguish it from
|
|
|
|
|
|
// generic conflicts).
|
|
|
|
|
|
for i in &too_many {
|
|
|
|
|
|
let body_value: Value = serde_json::from_slice(&results[*i].2).unwrap();
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body_value).unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
Some(omnigraph_server::api::ErrorCode::TooManyRequests),
|
|
|
|
|
|
"429 body must carry code=too_many_requests; idx {} got {:?}",
|
|
|
|
|
|
i,
|
|
|
|
|
|
error.code,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate the `Retry-After` header is set on every 429. Pinned by
|
|
|
|
|
|
// the same test so a future refactor that drops the header from
|
|
|
|
|
|
// `IntoResponse for ApiError` turns this red. The constant
|
|
|
|
|
|
// matches `crates/omnigraph-server/src/lib.rs::ApiError::into_response`.
|
|
|
|
|
|
for i in &too_many {
|
|
|
|
|
|
let retry_after = results[*i]
|
|
|
|
|
|
.1
|
|
|
|
|
|
.get(axum::http::header::RETRY_AFTER)
|
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
|
.map(str::to_string);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
retry_after.is_some(),
|
|
|
|
|
|
"429 response must include a Retry-After header; idx {} headers were: {:?}",
|
|
|
|
|
|
i,
|
|
|
|
|
|
results[*i].1,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 20:49:41 +03:00
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn oversized_request_body_returns_payload_too_large() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_loaded_graph().await;
|
2026-04-10 20:49:41 +03:00
|
|
|
|
let oversized = "x".repeat(1_100_000);
|
|
|
|
|
|
let response = app
|
|
|
|
|
|
.clone()
|
|
|
|
|
|
.oneshot(
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/read")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(oversized))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
|
|
|
|
|
}
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
|
|
|
|
|
|
// ─── MR-723 default-deny mode (State 2: tokens without policy) ──────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// `authorize_request` returns 403 for every action except `Read` when a
|
|
|
|
|
|
// PolicyEngine is not installed but bearer tokens are configured. Pinned
|
|
|
|
|
|
// by the three tests below — Read allowed, Change/SchemaApply denied —
|
|
|
|
|
|
// to prevent regressing back to the pre-MR-723 "tokens configured but
|
|
|
|
|
|
// no policy = fully open" trap.
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn default_deny_mode_allows_read_for_authenticated_actor() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-andrew", "demo-token")],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let (status, _body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/snapshot")
|
|
|
|
|
|
.method(Method::GET)
|
|
|
|
|
|
.header(AUTHORIZATION, "Bearer demo-token")
|
|
|
|
|
|
.body(Body::empty())
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn default_deny_mode_rejects_change_with_forbidden() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-andrew", "demo-token")],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let change = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "DefaultDeny", "age": 1 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header(AUTHORIZATION, "Bearer demo-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
error.error.contains("default-deny"),
|
|
|
|
|
|
"expected default-deny in error message, got: {}",
|
|
|
|
|
|
error.error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn default_deny_mode_rejects_schema_apply_with_forbidden() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-andrew", "demo-token")],
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
|
|
let req = SchemaApplyRequest {
|
|
|
|
|
|
schema_source: additive_schema_with_nickname(),
|
2026-05-24 16:46:00 +01:00
|
|
|
|
..Default::default()
|
policy: server 3-state default-deny matrix (MR-723) (#105)
Closes the "tokens but no policy" trap. Pre-MR-723, an operator who
configured bearer tokens and forgot to set policy.file got a server
that required auth and then permitted every action — the illusion of
protection. After MR-723, that configuration is default-deny: only
`read` actions succeed; every other action returns HTTP 403.
Three startup states, classified deterministically:
- **Open** — no tokens, no policy. Requires explicit
`--unauthenticated` flag or `OMNIGRAPH_UNAUTHENTICATED=1`; otherwise
`serve()` refuses to start. Forces the operator to opt in to
"fully open dev mode" so it can't happen accidentally.
- **DefaultDeny** — tokens configured, no policy. `authorize_request`
rejects every action except `Read` with 403. The warn-log on
startup names the misconfiguration explicitly.
- **PolicyEnabled** — policy file configured. Cedar evaluates every
request, unchanged from pre-MR-723.
What landed:
- `ServerConfig.allow_unauthenticated: bool` + `--unauthenticated` flag
on the `omnigraph-server` bin + `OMNIGRAPH_UNAUTHENTICATED` env var
(`load_server_settings` honors both).
- New `classify_server_runtime_state(has_tokens, has_policy,
allow_unauthenticated) -> Result<ServerRuntimeState>` pure function.
`serve()` calls it before opening the engine and bails with a clear
error when the operator hits the no-tokens-no-policy-no-flag cell.
- `authorize_request` state-2 branch: when `policy_engine()` is None
but the bearer-auth middleware delivered an authenticated actor, any
action other than `Read` returns 403 with a message that names the
misconfiguration.
- `AppState::with_policy_engine(self, engine)` builder method so
integration tests that need a custom workload (`new_with_workload`)
can still install a permit-all policy without a new constructor.
- `app_for_loaded_repo_with_auth(token)` and
`app_for_loaded_repo_with_auth_tokens(tokens)` test helpers now
install a permit-all policy alongside tokens — they previously
represented the "tokens but no policy" state that MR-723 makes
default-deny, and tests that don't care about policy were
inadvertently coupled to the loophole.
Tests:
- `classify_*` unit tests (3) — every cell of the matrix.
- `default_deny_mode_allows_read_for_authenticated_actor` — GET
/snapshot succeeds with bearer token + no policy.
- `default_deny_mode_rejects_change_with_forbidden` — POST /change
rejected with 403 + "default-deny" message.
- `default_deny_mode_rejects_schema_apply_with_forbidden` — POST
/schema/apply rejected with 403 + "default-deny" message.
- New `app_for_repo_with_auth_tokens_only(schema, tokens)` helper
builds the State-2 fixture without policy. The pre-MR-723 helpers
`app_for_loaded_repo_with_auth*` shift semantics to "tokens +
permit-all" so existing tests retain their original intent.
docs/user/policy.md: new "Server runtime states (MR-723)" section
documents the matrix and the explicit `--unauthenticated` opt-in.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:02:26 +03:00
|
|
|
|
};
|
|
|
|
|
|
let (status, body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header(AUTHORIZATION, "Bearer demo-token")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&req).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
|
|
|
|
|
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
error.error.contains("default-deny"),
|
|
|
|
|
|
"expected default-deny in error message, got: {}",
|
|
|
|
|
|
error.error
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
|
|
|
|
|
|
// ─── SDK ↔ HTTP decision parity (MR-722 PR A) ─────────────────────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// Engine and HTTP both consult Cedar via `PolicyChecker::check()`; by
|
|
|
|
|
|
// construction they cannot disagree on a decision. These tests pin that
|
|
|
|
|
|
// property explicitly so a future refactor that introduces a separate
|
|
|
|
|
|
// auth path (or copy-pastes Cedar evaluation logic) turns red.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Four cases cover the per-action scope shapes:
|
|
|
|
|
|
// * Change on a protected branch via `mutate_as` / POST /change
|
|
|
|
|
|
// * Change with an actor that has no permit
|
|
|
|
|
|
// * BranchMerge to a protected target via `branch_merge_as` / POST /branches/merge
|
|
|
|
|
|
// * BranchMerge with an actor that has no permit
|
|
|
|
|
|
|
|
|
|
|
|
const PARITY_POLICY_YAML: &str = r#"
|
|
|
|
|
|
version: 1
|
|
|
|
|
|
groups:
|
|
|
|
|
|
team: [act-bruno]
|
|
|
|
|
|
admins: [act-ragnor]
|
|
|
|
|
|
protected_branches: [main]
|
|
|
|
|
|
rules:
|
|
|
|
|
|
- id: admins-change-anywhere
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: admins }
|
|
|
|
|
|
actions: [change]
|
|
|
|
|
|
branch_scope: any
|
|
|
|
|
|
- id: admins-merge-to-protected
|
|
|
|
|
|
allow:
|
|
|
|
|
|
actors: { group: admins }
|
|
|
|
|
|
actions: [branch_merge]
|
|
|
|
|
|
target_branch_scope: protected
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
|
|
|
|
enum ParityDecision {
|
|
|
|
|
|
Allow,
|
|
|
|
|
|
Deny,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn build_parity_graph() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
|
|
|
|
|
// Build a graph with `main` loaded and a `feature` branch ready for
|
|
|
|
|
|
// merge. Returns the graph path and a written policy.yaml path.
|
|
|
|
|
|
let temp = init_loaded_graph().await;
|
|
|
|
|
|
let graph = graph_path(temp.path());
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
{
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
db.load_as(
|
|
|
|
|
|
"feature",
|
|
|
|
|
|
r#"{"type":"Person","data":{"name":"ParityEve","age":29}}"#,
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
let policy_path = temp.path().join("policy.yaml");
|
|
|
|
|
|
fs::write(&policy_path, PARITY_POLICY_YAML).unwrap();
|
2026-05-24 16:46:00 +01:00
|
|
|
|
(temp, graph, policy_path)
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn sdk_change_decision(graph: &Path, policy_path: &Path, actor: &str) -> ParityDecision {
|
|
|
|
|
|
let policy = PolicyEngine::load(policy_path, graph.to_string_lossy().as_ref()).unwrap();
|
|
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap())
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.with_policy(Arc::new(policy) as Arc<dyn PolicyChecker>);
|
|
|
|
|
|
let mut params: omnigraph_compiler::ParamMap = Default::default();
|
|
|
|
|
|
// Parameter keys are bare names (no `$` prefix); the runtime resolves
|
|
|
|
|
|
// `$name` references in the query body to `params["name"]`.
|
|
|
|
|
|
params.insert(
|
|
|
|
|
|
"name".to_string(),
|
|
|
|
|
|
omnigraph_compiler::Literal::String("ParityCharlie".to_string()),
|
|
|
|
|
|
);
|
|
|
|
|
|
params.insert("age".to_string(), omnigraph_compiler::Literal::Integer(30));
|
|
|
|
|
|
let result = db
|
2026-05-24 16:46:00 +01:00
|
|
|
|
.mutate_as(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
MUTATION_QUERIES,
|
|
|
|
|
|
"insert_person",
|
|
|
|
|
|
¶ms,
|
|
|
|
|
|
Some(actor),
|
|
|
|
|
|
)
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
.await;
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(_) => ParityDecision::Allow,
|
|
|
|
|
|
Err(OmniError::Policy(_)) => ParityDecision::Deny,
|
|
|
|
|
|
Err(other) => panic!("unexpected SDK error for change: {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn http_change_decision(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph: &Path,
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
policy_path: &PathBuf,
|
|
|
|
|
|
actor: &str,
|
|
|
|
|
|
token: &str,
|
|
|
|
|
|
) -> ParityDecision {
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
vec![(actor.to_string(), token.to_string())],
|
|
|
|
|
|
Some(policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
let req = ChangeRequest {
|
|
|
|
|
|
query_source: MUTATION_QUERIES.to_string(),
|
|
|
|
|
|
query_name: Some("insert_person".to_string()),
|
|
|
|
|
|
params: Some(json!({ "name": "ParityCharlie", "age": 30 })),
|
|
|
|
|
|
branch: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, _body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/change")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header(AUTHORIZATION, format!("Bearer {token}"))
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&req).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
match status {
|
|
|
|
|
|
StatusCode::OK => ParityDecision::Allow,
|
|
|
|
|
|
StatusCode::FORBIDDEN => ParityDecision::Deny,
|
|
|
|
|
|
other => panic!("unexpected HTTP status for change: {other}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
async fn sdk_merge_decision(graph: &Path, policy_path: &Path, actor: &str) -> ParityDecision {
|
|
|
|
|
|
let policy = PolicyEngine::load(policy_path, graph.to_string_lossy().as_ref()).unwrap();
|
|
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap())
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.with_policy(Arc::new(policy) as Arc<dyn PolicyChecker>);
|
|
|
|
|
|
let result = db.branch_merge_as("feature", "main", Some(actor)).await;
|
|
|
|
|
|
match result {
|
|
|
|
|
|
Ok(_) => ParityDecision::Allow,
|
|
|
|
|
|
Err(OmniError::Policy(_)) => ParityDecision::Deny,
|
|
|
|
|
|
Err(other) => panic!("unexpected SDK error for branch_merge: {other:?}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn http_merge_decision(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph: &Path,
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
policy_path: &PathBuf,
|
|
|
|
|
|
actor: &str,
|
|
|
|
|
|
token: &str,
|
|
|
|
|
|
) -> ParityDecision {
|
|
|
|
|
|
let state = AppState::open_with_bearer_tokens_and_policy(
|
2026-05-24 16:46:00 +01:00
|
|
|
|
graph.to_string_lossy().to_string(),
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
vec![(actor.to_string(), token.to_string())],
|
|
|
|
|
|
Some(policy_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let app = build_app(state);
|
|
|
|
|
|
let req = BranchMergeRequest {
|
|
|
|
|
|
source: "feature".to_string(),
|
|
|
|
|
|
target: Some("main".to_string()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let (status, _body) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.uri("/branches/merge")
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.header(AUTHORIZATION, format!("Bearer {token}"))
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.body(Body::from(serde_json::to_vec(&req).unwrap()))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
match status {
|
|
|
|
|
|
StatusCode::OK => ParityDecision::Allow,
|
|
|
|
|
|
StatusCode::FORBIDDEN => ParityDecision::Deny,
|
|
|
|
|
|
other => panic!("unexpected HTTP status for branch_merge: {other}"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_decision_parity_change_admin_on_main_allowed() {
|
|
|
|
|
|
// (act-ragnor, change, main) — admins-change-anywhere rule applies.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
// Both SDK and HTTP must allow. Each path uses its own fresh graph
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
// because allow→side-effects.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_t1, graph1, policy1) = build_parity_graph().await;
|
|
|
|
|
|
let sdk = sdk_change_decision(&graph1, &policy1, "act-ragnor").await;
|
|
|
|
|
|
let (_t2, graph2, policy2) = build_parity_graph().await;
|
|
|
|
|
|
let http = http_change_decision(&graph2, &policy2, "act-ragnor", "ragnor-token").await;
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(sdk, ParityDecision::Allow) && matches!(http, ParityDecision::Allow),
|
|
|
|
|
|
"SDK={sdk:?} HTTP={http:?} — should both Allow",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_decision_parity_change_team_on_main_denied() {
|
|
|
|
|
|
// (act-bruno, change, main) — no rule grants bruno change on
|
2026-05-24 16:46:00 +01:00
|
|
|
|
// protected. Both SDK and HTTP must deny. Same graph is reusable
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
// because deny→no side-effects.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, graph, policy) = build_parity_graph().await;
|
|
|
|
|
|
let sdk = sdk_change_decision(&graph, &policy, "act-bruno").await;
|
|
|
|
|
|
let http = http_change_decision(&graph, &policy, "act-bruno", "bruno-token").await;
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(sdk, ParityDecision::Deny) && matches!(http, ParityDecision::Deny),
|
|
|
|
|
|
"SDK={sdk:?} HTTP={http:?} — should both Deny",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_decision_parity_branch_merge_admin_allowed() {
|
|
|
|
|
|
// (act-ragnor, branch_merge, feature→main) — admins-merge-to-protected
|
2026-05-24 16:46:00 +01:00
|
|
|
|
// rule applies. Both Allow. Each path uses its own fresh graph —
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
// a successful merge consumes the feature branch's commit on main.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_t1, graph1, policy1) = build_parity_graph().await;
|
|
|
|
|
|
let sdk = sdk_merge_decision(&graph1, &policy1, "act-ragnor").await;
|
|
|
|
|
|
let (_t2, graph2, policy2) = build_parity_graph().await;
|
|
|
|
|
|
let http = http_merge_decision(&graph2, &policy2, "act-ragnor", "ragnor-token").await;
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(sdk, ParityDecision::Allow) && matches!(http, ParityDecision::Allow),
|
|
|
|
|
|
"SDK={sdk:?} HTTP={http:?} — should both Allow",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn policy_decision_parity_branch_merge_team_denied() {
|
|
|
|
|
|
// (act-bruno, branch_merge, feature→main) — no rule grants bruno
|
|
|
|
|
|
// branch_merge. Both Deny.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (_temp, graph, policy) = build_parity_graph().await;
|
|
|
|
|
|
let sdk = sdk_merge_decision(&graph, &policy, "act-bruno").await;
|
|
|
|
|
|
let http = http_merge_decision(&graph, &policy, "act-bruno", "bruno-token").await;
|
tests: policy chassis e2e gap-fills (MR-722 follow-up) (#106)
* tests: policy chassis e2e gap-fills (MR-722 follow-up)
Audit after PRs #101-105 surfaced real e2e gaps in the policy chassis
that could let regressions ride through silently. Coverage was strong
at the SDK level (18 chassis tests) and reasonable at HTTP (12+ policy
tests), but the CLI×writer matrix was asymmetric (only `change` tested
end-to-end), the `cli.actor` config-only precedence path was untested,
the `OMNIGRAPH_UNAUTHENTICATED` env-var read path was unexercised,
`serve()`'s startup-refusal propagation was structural-review only,
and engine↔HTTP decision parity was a structural property without a
test pinning it. This commit closes those gaps.
Added (15 new tests, all test-only):
* `policy_engine_chassis.rs` (+2): `load_file_as` allow + deny pair —
PR #104 added the actor-aware mirror of `load_file` but it was only
exercised via CLI integration; this is direct-SDK coverage.
* `omnigraph-server/src/lib.rs` mod tests (+2):
- `unauthenticated_env_var_classification` — consolidated single
test (process-global env var; running parallel would race) that
pins truthy values, falsy values, unset, and CLI-flag-overrides-
env behavior of the `OMNIGRAPH_UNAUTHENTICATED` read path inside
`load_server_settings`.
- `serve_refuses_to_start_in_state_1_without_unauthenticated` —
`#[serial]` integration test. Clears all bearer-token env vars,
builds a `ServerConfig` with no policy file and no flag, calls
`serve(config).await`, asserts Err before any side-effecting
work (Lance dataset open, TcpListener::bind). Guards the
classifier→serve propagation path so a future refactor that
drops the call turns red.
* `omnigraph-server/tests/server.rs` (+4): `policy_decision_parity_*`
— four cases (Change×allowed+denied, BranchMerge×allowed+denied).
Each case runs the same Cedar decision via both SDK
(`Omnigraph::with_policy().mutate_as` / `branch_merge_as`) and HTTP
(`POST /change` / `POST /branches/merge`) and asserts both either
Allow or Deny. The structural property (both paths call
`PolicyChecker::check`) is now test-asserted.
* `omnigraph-cli/tests/system_local.rs` (+8): the CLI×writer matrix
fan-out:
- `local_cli_load_enforces_engine_layer_policy`
- `local_cli_ingest_enforces_engine_layer_policy`
- `local_cli_schema_apply_enforces_engine_layer_policy`
- `local_cli_branch_create_enforces_engine_layer_policy`
- `local_cli_branch_delete_enforces_engine_layer_policy`
- `local_cli_branch_merge_enforces_engine_layer_policy`
Each: one denied case (`--as act-bruno` against protected main) +
one allowed case (`--as act-ragnor` via existing/extended admins-*
rules).
Plus:
- `local_cli_actor_from_config_used_when_no_flag` — proves the
config-only precedence path works.
- `local_cli_actor_flag_overrides_config_actor` — proves the
`--as` flag wins over `cli.actor` in the config.
Adds `local_policy_config_with_actor` helper. Extends
`POLICY_E2E_YAML` with `admins-branch-ops` (BranchCreate +
BranchDelete) and `admins-schema-apply` rules so the CLI×writer
matrix has positive-case rule coverage.
Verification: all new tests pass; full `cargo test --workspace
--locked` is green; `scripts/check-agents-md.sh` passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* tests: serialize env-touching server lib tests to fix CI flake
CI flake on PR #106's Test Workspace job: two of the new tests
(`serve_refuses_to_start_in_state_1_without_unauthenticated` and
`unauthenticated_env_var_classification`) raced against
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`,
which sets `OMNIGRAPH_SERVER_BEARER_TOKEN` via `EnvGuard`.
While `serve_refuses` was mid-execution with its EnvGuard cleared,
the bearer-token test's EnvGuard had `OMNIGRAPH_SERVER_BEARER_TOKEN`
set; `resolve_token_source()` saw it and classified the runtime
state as `DefaultDeny` rather than refusing — so the test panicked
with "Dataset at path X not found" instead of the expected refusal
message. The unauthenticated test had the symmetric failure: its
`OMNIGRAPH_UNAUTHENTICATED="anything"` got overwritten by a peer
`EnvGuard` drop.
Fix: mark every test that uses `EnvGuard` with `#[serial]` so they
serialize against each other (default key). Already on
`serve_refuses_to_start_in_state_1_without_unauthenticated`; added
to `unauthenticated_env_var_classification` and
`server_bearer_tokens_from_env_reads_legacy_token_and_token_file`.
The `parse_bearer_tokens_json_*` tests don't touch env vars and
stay parallel.
Locally green (36 tests pass on my workstation); the parallelism
issue is CI-runner-specific (more aggressive thread interleaving)
but the fix is universal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:25:04 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(sdk, ParityDecision::Deny) && matches!(http, ParityDecision::Deny),
|
|
|
|
|
|
"SDK={sdk:?} HTTP={http:?} — should both Deny",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
|
|
|
|
|
|
// ─── MR-694 PR B: HTTP soft + hard drop semantics + data preservation ────
|
|
|
|
|
|
//
|
|
|
|
|
|
// SDK-level drop semantics are pinned in `crates/omnigraph/tests/schema_apply.rs`.
|
|
|
|
|
|
// These HTTP-side tests mirror the assertions through POST /schema/apply
|
|
|
|
|
|
// and exercise the new `allow_data_loss` field (closes the gap where
|
|
|
|
|
|
// the schema-lint chassis v1.2 shipped Hard mode on the CLI but the
|
|
|
|
|
|
// HTTP request struct had no equivalent field).
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_apply_route_soft_drops_property_via_http() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
// Load a row that has the column we're about to drop.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
{
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
db.load(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
r#"{"type":"Person","data":{"name":"PreDrop","age":42}}"#,
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
}
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let pre_version = manifest_dataset_version(&graph).await;
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
|
|
|
|
|
|
let (status, payload) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: schema_without_age(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
|
|
|
|
|
|
|
|
// Catalog reflects the drop: `age` is gone from the live schema.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
!reopened.catalog().node_types["Person"]
|
|
|
|
|
|
.properties
|
|
|
|
|
|
.contains_key("age"),
|
|
|
|
|
|
"catalog should not contain `age` after drop"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Soft drop preserves the prior version — `age` is still readable
|
|
|
|
|
|
// via time travel to the pre-drop manifest version. Mirrors the
|
|
|
|
|
|
// SDK-side assertion in `apply_schema_drops_a_nullable_property_softly_preserves_prior_version`.
|
|
|
|
|
|
let pre_drop_snapshot = reopened.snapshot_at_version(pre_version).await.unwrap();
|
|
|
|
|
|
let pre_drop_ds = pre_drop_snapshot.open("node:Person").await.unwrap();
|
|
|
|
|
|
let pre_drop_fields = pre_drop_ds
|
|
|
|
|
|
.schema()
|
|
|
|
|
|
.fields
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|f| f.name.clone())
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
pre_drop_fields.iter().any(|f| f == "age"),
|
|
|
|
|
|
"soft drop should leave the pre-drop dataset's `age` column \
|
|
|
|
|
|
time-travel-reachable; got fields {pre_drop_fields:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_apply_route_soft_drops_node_type_via_http() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
|
|
|
|
|
|
let (status, payload) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: schema_without_company(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
|
|
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
!reopened.catalog().node_types.contains_key("Company"),
|
|
|
|
|
|
"catalog should not contain `Company` after drop"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!reopened.catalog().edge_types.contains_key("WorksAt"),
|
|
|
|
|
|
"catalog should not contain `WorksAt` after cascade"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_apply_route_hard_drops_property_with_allow_data_loss() {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
{
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
db.load(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
r#"{"type":"Person","data":{"name":"PreDropHard","age":50}}"#,
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply with allow_data_loss=true → Hard mode promotion.
|
|
|
|
|
|
let (status, payload) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: schema_without_age(),
|
|
|
|
|
|
allow_data_loss: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
|
|
|
|
|
|
|
|
// Catalog reflects the drop.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
!reopened.catalog().node_types["Person"]
|
|
|
|
|
|
.properties
|
|
|
|
|
|
.contains_key("age"),
|
|
|
|
|
|
"catalog should not contain `age` after Hard drop"
|
|
|
|
|
|
);
|
|
|
|
|
|
// Plan steps should show DropMode::Hard for property drops.
|
|
|
|
|
|
let steps = payload["steps"].as_array().expect("steps array");
|
|
|
|
|
|
let drop_step = steps
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s["kind"] == "drop_property")
|
|
|
|
|
|
.expect("plan should include drop_property step");
|
|
|
|
|
|
let mode = &drop_step["mode"];
|
2026-05-24 16:46:00 +01:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
mode, "hard",
|
|
|
|
|
|
"expected hard mode under allow_data_loss=true"
|
|
|
|
|
|
);
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_apply_route_keeps_drops_soft_without_flag() {
|
|
|
|
|
|
// Symmetric to the Hard test: same schema change, but no
|
|
|
|
|
|
// allow_data_loss flag → drops stay Soft (prior column data
|
|
|
|
|
|
// remains time-travel-reachable). Pins the default semantics
|
|
|
|
|
|
// against accidental Hard promotion.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
|
|
|
|
|
|
let (status, payload) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: schema_without_age(),
|
|
|
|
|
|
allow_data_loss: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
|
|
|
|
|
|
|
|
let steps = payload["steps"].as_array().expect("steps array");
|
|
|
|
|
|
let drop_step = steps
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s["kind"] == "drop_property")
|
|
|
|
|
|
.expect("plan should include drop_property step");
|
|
|
|
|
|
let mode = &drop_step["mode"];
|
|
|
|
|
|
assert_eq!(mode, "soft", "expected soft mode without allow_data_loss");
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let _ = graph;
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
|
|
|
|
async fn schema_apply_route_additive_property_preserves_existing_rows() {
|
|
|
|
|
|
// SDK suite covers rename and drop data preservation. Additive
|
|
|
|
|
|
// AddProperty wasn't pinned with a row-count check anywhere.
|
|
|
|
|
|
// Load N rows, apply schema adding nullable property, verify
|
|
|
|
|
|
// every row is still readable and the new column is null.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
|
|
|
|
|
&[("act-ragnor", "admin-token")],
|
|
|
|
|
|
SCHEMA_APPLY_POLICY_YAML,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let graph = graph_path(temp.path());
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
|
|
|
|
|
|
// Standard fixture data: 4 Persons + 1 Company. Load it.
|
|
|
|
|
|
let pre_count = {
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
db.load(
|
|
|
|
|
|
"main",
|
|
|
|
|
|
&fs::read_to_string(fixture("test.jsonl")).unwrap(),
|
|
|
|
|
|
LoadMode::Append,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let snap = db
|
|
|
|
|
|
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
|
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
snap.entry("node:Person").expect("Person").row_count
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(pre_count > 0, "fixture should have loaded Person rows");
|
|
|
|
|
|
|
|
|
|
|
|
let (status, payload) = json_response(
|
|
|
|
|
|
&app,
|
|
|
|
|
|
Request::builder()
|
|
|
|
|
|
.method(Method::POST)
|
|
|
|
|
|
.uri("/schema/apply")
|
|
|
|
|
|
.header("content-type", "application/json")
|
|
|
|
|
|
.header("authorization", "Bearer admin-token")
|
|
|
|
|
|
.body(Body::from(
|
|
|
|
|
|
serde_json::to_vec(&SchemaApplyRequest {
|
|
|
|
|
|
schema_source: additive_schema_with_nickname(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
))
|
|
|
|
|
|
.unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await;
|
|
|
|
|
|
assert_eq!(status, StatusCode::OK);
|
|
|
|
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
|
|
|
|
|
|
|
|
// Row count preserved.
|
2026-05-24 16:46:00 +01:00
|
|
|
|
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
|
|
|
|
|
let snap = db
|
|
|
|
|
|
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
schema: HTTP allow_data_loss exposure + e2e drop coverage (MR-694 follow-up) (#107)
The schema-lint chassis v1.2 (PR #100) shipped `--allow-data-loss` on
the CLI, but `SchemaApplyRequest` had no equivalent field — Hard-mode
drops were CLI-only. This commit closes that feature gap and adds e2e
test coverage for drop modes across HTTP + CLI, plus data preservation
on additive apply, plus a CLI↔SDK plan-parity assertion.
Feature gap closed:
- `crates/omnigraph-server/src/api.rs` — added `allow_data_loss: bool`
(default false via `#[serde(default)]`) to `SchemaApplyRequest`.
Added `Default` derive so test usages can use `..Default::default()`.
- `crates/omnigraph-server/src/lib.rs` — `server_schema_apply` now
constructs `SchemaApplyOptions { allow_data_loss: request.allow_data_loss }`
and threads through to `apply_schema_as`.
- `crates/omnigraph-cli/src/main.rs` — remote-URI schema-apply path
used to bail with "--allow-data-loss not yet supported on remote";
now forwards the flag into the JSON payload so the CLI behaves
identically against local and remote URIs.
- `openapi.json` — regenerated; only diff is the new field on
`SchemaApplyRequest`.
Tests added (8 new):
* `crates/omnigraph-server/tests/server.rs` (+5):
- `schema_apply_route_soft_drops_property_via_http` — POST schema
removing nullable property, verify catalog reflects the drop AND
`snapshot_at_version(pre)` still has `age` in the field list
(time-travel reachability is the Soft contract).
- `schema_apply_route_soft_drops_node_type_via_http` — POST schema
removing `Company` node + cascading `WorksAt` edge.
- `schema_apply_route_hard_drops_property_with_allow_data_loss` —
POST with `allow_data_loss: true`, verify plan step reports
`mode: hard`.
- `schema_apply_route_keeps_drops_soft_without_flag` — same schema
without flag, verify `mode: soft`. Pins default semantics against
accidental Hard promotion.
- `schema_apply_route_additive_property_preserves_existing_rows` —
load fixture, POST adding nullable property, verify row count
preserved (SDK suite covers data preservation on drops + renames;
additive AddProperty wasn't pinned).
Plus helpers `schema_without_age` and `schema_without_company`.
* `crates/omnigraph-cli/tests/cli.rs` (+3):
- `schema_apply_allow_data_loss_flag_promotes_drops_to_hard` — CLI
`omnigraph schema apply --allow-data-loss --schema X.pg --json`,
verify plan step has `mode: hard`.
- `schema_apply_without_allow_data_loss_keeps_soft_drops` — without
flag, verify Soft.
- `schema_plan_parity_cli_and_sdk` — same `.pg` source through
`Omnigraph::plan_schema` (SDK) and `omnigraph schema plan --json`
(CLI), assert the steps array is byte-identical post-JSON. HTTP
has no `/schema/plan` endpoint; apply-side parity is implicitly
covered by the HTTP drop tests + CLI drop tests using identical
fixtures.
Docs:
- `docs/user/schema-language.md` — new "Destructive drops" section
documenting Soft vs Hard semantics and that `allow_data_loss` is
now honored uniformly across CLI / HTTP / SDK.
Verification: every new test passes; full `cargo test --workspace --locked`
green; `scripts/check-agents-md.sh` passes.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 01:56:46 +03:00
|
|
|
|
.await
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let post_count = snap.entry("node:Person").expect("Person").row_count;
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
post_count, pre_count,
|
|
|
|
|
|
"AddProperty should preserve row count",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|