Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ jobs:
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals run examples/hello.json --input examples/hello.input.json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json --json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json --junit
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals test examples/agent-tool-result.json examples/agent-tool-result.suite.json --json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals schema workflow --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals schema suite --compact
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ use semantic versioning while the public API remains pre-1.0.
- Competitive positioning, flagship use cases, and measurable adoption gates.
- Bundled JSON Schema Draft 2020-12 documents with CLI and Python discovery APIs.
- Deterministic, value-redacted JUnit XML reports for native CI ingestion.
- Bounded `merge` and `pick` operations for shallow object composition and explicit
top-level output allowlists.
- An agent tool-result example with approval, required-field, and adversarial extra-field
regression cases.

### Changed

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on PyPI, so install it from a checkout or a locally built wheel.
## What it does

- Validates a versioned JSON workflow before execution.
- Runs `set` and `assert` steps in a fixed order.
- Runs `set`, `assert`, `merge`, and `pick` steps in a fixed order.
- Runs checked-in suites that prove expected outputs and expected failures.
- Renders values from `input`, `defaults`, and completed `steps`.
- Emits deterministic JSON with no timestamps, random IDs, or hidden state.
Expand Down Expand Up @@ -41,6 +41,7 @@ On macOS or Linux, use `.venv/bin/python` in place of `.venv\Scripts\python`.
.venv\Scripts\samsarix-spirals validate examples/hello.json
.venv\Scripts\samsarix-spirals run examples/hello.json --input examples/hello.input.json
.venv\Scripts\samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json
.venv\Scripts\samsarix-spirals test examples/agent-tool-result.json examples/agent-tool-result.suite.json
.venv\Scripts\samsarix-spirals schema workflow --compact
```

Expand Down Expand Up @@ -82,6 +83,10 @@ samsarix-spirals test workflow.json workflow.suite.json --junit

See [`examples/release-policy.suite.json`](examples/release-policy.suite.json) for a
release approval gate with both successful and rejected cases.
[`examples/agent-tool-result.suite.json`](examples/agent-tool-result.suite.json) proves
that an approved agent result is enriched, restricted to an explicit key allowlist, and
rejected when required output is absent. Extra reasoning and credential-shaped fields
never reach the workflow output.

## JSON Schemas and CI reports

Expand Down
10 changes: 7 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ this boundary.

### 0.3 — Useful deterministic shaping

- Add a small, orthogonal operation set for object merge, key selection, list mapping,
filtering, and string normalization without arbitrary expressions.
- [x] Add bounded shallow object merge and explicit top-level key selection without
arbitrary expressions.
- [ ] Add bounded list mapping, filtering, and string normalization without arbitrary
expressions.
- Add an `explain` command that shows dependencies and referenced input paths without
executing the workflow.
- Define compatibility and deprecation rules for every schema-visible operation.
- Prove agent-output and repository-policy examples with adversarial fixtures.
- [x] Prove an agent-output contract with adversarial extra-field, approval, and
required-field fixtures.
- [ ] Prove a repository-policy example with adversarial fixtures.

### 0.4 — Repository adoption

Expand Down
46 changes: 45 additions & 1 deletion docs/WORKFLOW_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ at most 1 MiB, at most 20 levels deep, and contain only finite JSON numbers.
Each step has exactly three fields:

- `id`: starts with a letter, then contains up to 63 letters, digits, `_`, or `-`.
- `uses`: one of `set` or `assert`.
- `uses`: one of `set`, `assert`, `merge`, or `pick`.
- `with`: an object containing operation arguments.

IDs are unique and case-sensitive. A step can reference only earlier steps. Execution is
Expand Down Expand Up @@ -86,6 +86,50 @@ Ordered operands must both be numbers or both strings. Booleans are not treated
numbers. A successful assertion exposes `{"passed": true, "value": ...}`. A failed
assertion stops the workflow and makes the CLI exit `1`.

### `merge`

`merge` requires `objects`, which must render to an array of objects. It creates a new
object by applying those objects from left to right. When a key occurs more than once,
the value in the later object wins.

```json
{
"id": "enriched",
"uses": "merge",
"with": {
"objects": [
{"source": "agent", "reviewed": true},
"{{ input.result }}"
]
}
}
```

The operation is shallow: nested objects are replaced, not recursively merged. Inputs
are copied, so later processing cannot mutate workflow defaults or prior step outputs.
The normal collection and total-value budgets apply to the result.

### `pick`

`pick` requires `object` and `keys`. It returns a new object containing only the named
top-level keys, in the order given by `keys`.

```json
{
"id": "public_result",
"uses": "pick",
"with": {
"object": "{{ steps.enriched }}",
"keys": ["ticket_id", "summary", "source"]
}
}
```

`keys` must render to an array of unique strings. By default, every named key is required
and a missing key fails the step. Set `required` to `false` to omit missing keys instead.
`pick` is an allowlist rather than a redaction list: every field that may leave the
workflow boundary must be named explicitly.

## Templates

Templates use `{{ reference.path }}`. Available roots are:
Expand Down
48 changes: 48 additions & 0 deletions examples/agent-tool-result.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"schema_version": 1,
"name": "agent-tool-result",
"description": "Gate an untrusted agent result and emit only explicitly allowed fields.",
"defaults": {
"policy_metadata": {
"source": "agent",
"reviewed": true
}
},
"steps": [
{
"id": "require_approval",
"uses": "assert",
"with": {
"value": "{{ input.approved }}",
"operator": "equals",
"expected": true,
"message": "agent result requires approval"
}
},
{
"id": "enriched",
"uses": "merge",
"with": {
"objects": [
"{{ defaults.policy_metadata }}",
"{{ input.result }}"
]
}
},
{
"id": "allowlist",
"uses": "pick",
"with": {
"object": "{{ steps.enriched }}",
"keys": [
"ticket_id",
"summary",
"priority",
"source",
"reviewed"
]
}
}
],
"output": "{{ steps.allowlist }}"
}
61 changes: 61 additions & 0 deletions examples/agent-tool-result.suite.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"suite_version": 1,
"name": "agent tool-result contract",
"cases": [
{
"name": "approved result is enriched and allowlisted",
"input": {
"approved": true,
"result": {
"ticket_id": "INC-42",
"summary": "Investigate latency regression",
"priority": "high",
"internal_reasoning": "must never leave the contract boundary",
"credential": "must never leave the contract boundary"
}
},
"expect": {
"output": {
"ticket_id": "INC-42",
"summary": "Investigate latency regression",
"priority": "high",
"source": "agent",
"reviewed": true
}
}
},
{
"name": "unapproved result is rejected",
"input": {
"approved": false,
"result": {
"ticket_id": "INC-42",
"summary": "Investigate latency regression",
"priority": "high"
}
},
"expect": {
"error": {
"step_id": "require_approval",
"message_contains": "requires approval"
}
}
},
{
"name": "missing required output field is rejected",
"input": {
"approved": true,
"result": {
"ticket_id": "INC-42",
"summary": "Investigate latency regression"
}
},
"expect": {
"error": {
"step_id": "allowlist",
"message_contains": "missing required key 'priority'"
}
}
}
]
}
54 changes: 53 additions & 1 deletion src/samsarix_spirals/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

STEP_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
TEMPLATE_PATTERN = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*)\s*}}")
SUPPORTED_OPERATIONS = frozenset({"assert", "set"})
SUPPORTED_OPERATIONS = frozenset({"assert", "merge", "pick", "set"})
ASSERT_OPERATORS = frozenset(
{
"contains",
Expand Down Expand Up @@ -147,6 +147,10 @@ def from_dict(cls, document: Mapping[str, object]) -> Workflow:

if uses == "assert":
_validate_assert(arguments, path, issues)
elif uses == "merge":
_validate_merge(arguments, path, issues)
elif uses == "pick":
_validate_pick(arguments, path, issues)
_validate_templates(arguments, f"{path}.with", seen_ids - {step_id}, defaults, issues)
steps.append(Step(id=step_id, uses=uses, arguments=arguments))

Expand Down Expand Up @@ -396,6 +400,54 @@ def _validate_assert(arguments: dict[str, JsonValue], path: str, issues: list[st
issues.append(f"{path}.with.message must be a string")


def _validate_merge(arguments: dict[str, JsonValue], path: str, issues: list[str]) -> None:
_reject_unknown_keys(arguments, {"objects"}, f"{path}.with", issues)
if "objects" not in arguments:
issues.append(f"{path}.with.objects is required for merge")
return
objects = arguments["objects"]
if isinstance(objects, str) and TEMPLATE_PATTERN.fullmatch(objects):
return
if not isinstance(objects, list):
issues.append(f"{path}.with.objects must be an array or exact template")
return
for index, value in enumerate(objects):
if isinstance(value, dict):
continue
if isinstance(value, str) and TEMPLATE_PATTERN.fullmatch(value):
continue
issues.append(f"{path}.with.objects[{index}] must be an object or exact template")


def _validate_pick(arguments: dict[str, JsonValue], path: str, issues: list[str]) -> None:
_reject_unknown_keys(arguments, {"keys", "object", "required"}, f"{path}.with", issues)
if "object" not in arguments:
issues.append(f"{path}.with.object is required for pick")
else:
value = arguments["object"]
if not isinstance(value, dict) and not (
isinstance(value, str) and TEMPLATE_PATTERN.fullmatch(value)
):
issues.append(f"{path}.with.object must be an object or exact template")
if "keys" not in arguments:
issues.append(f"{path}.with.keys is required for pick")
else:
keys = arguments["keys"]
if isinstance(keys, str) and TEMPLATE_PATTERN.fullmatch(keys):
pass
elif not isinstance(keys, list):
issues.append(f"{path}.with.keys must be an array or exact template")
else:
for index, key in enumerate(keys):
if not isinstance(key, str):
issues.append(f"{path}.with.keys[{index}] must be a string")
required = arguments.get("required")
if required is not None and not isinstance(required, (bool, str)):
issues.append(f"{path}.with.required must be a boolean or template")
elif isinstance(required, str) and not TEMPLATE_PATTERN.fullmatch(required):
issues.append(f"{path}.with.required must be a boolean or exact template")


def _validate_templates(
value: object,
path: str,
Expand Down
53 changes: 53 additions & 0 deletions src/samsarix_spirals/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ def run_workflow(
def _execute_step(step: Step, arguments: dict[str, JsonValue]) -> JsonValue:
if step.uses == "set":
return copy.deepcopy(arguments)
if step.uses == "merge":
return _merge_objects(arguments, step_id=step.id)
if step.uses == "pick":
return _pick_keys(arguments, step_id=step.id)
if step.uses == "assert":
value = arguments.get("value")
expected = arguments.get("expected")
Expand All @@ -134,6 +138,55 @@ def _execute_step(step: Step, arguments: dict[str, JsonValue]) -> JsonValue:
)


def _merge_objects(arguments: dict[str, JsonValue], *, step_id: str) -> JsonValue:
objects = arguments.get("objects")
if not isinstance(objects, list):
raise WorkflowExecutionError("merge objects must render to an array", step_id=step_id)
merged: dict[str, JsonValue] = {}
for index, value in enumerate(objects):
if not isinstance(value, dict):
raise WorkflowExecutionError(
f"merge objects[{index}] must render to an object", step_id=step_id
)
merged.update(copy.deepcopy(value))
if len(merged) > MAX_COLLECTION_ITEMS: # pragma: no cover - render budget is tighter
raise WorkflowExecutionError(
f"merged object exceeds the {MAX_COLLECTION_ITEMS}-key limit", step_id=step_id
)
return merged


def _pick_keys(arguments: dict[str, JsonValue], *, step_id: str) -> JsonValue:
value = arguments.get("object")
keys = arguments.get("keys")
required = arguments.get("required", True)
if not isinstance(value, dict):
raise WorkflowExecutionError("pick object must render to an object", step_id=step_id)
if not isinstance(keys, list):
raise WorkflowExecutionError("pick keys must render to an array", step_id=step_id)
if not isinstance(required, bool):
raise WorkflowExecutionError("pick required must render to a boolean", step_id=step_id)

selected: dict[str, JsonValue] = {}
seen: set[str] = set()
for index, key in enumerate(keys):
if not isinstance(key, str):
raise WorkflowExecutionError(
f"pick keys[{index}] must render to a string", step_id=step_id
)
if key in seen:
raise WorkflowExecutionError(f"pick key {key!r} is duplicated", step_id=step_id)
seen.add(key)
if key not in value:
if required:
raise WorkflowExecutionError(
f"pick object is missing required key {key!r}", step_id=step_id
)
continue
selected[key] = copy.deepcopy(value[key])
return selected


def _evaluate_assertion(value: JsonValue, operator: str, expected: JsonValue) -> bool:
if operator == "equals":
return value == expected
Expand Down
Loading
Loading