diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 2cbe868..588469d 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 24, - "id": "CHG-0024-encrypted-file-secret-store-via-swift-crypto-issue-35", + "sequence": 25, + "id": "CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/approvals.json b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/approvals.json new file mode 100644 index 0000000..3a4e8fa --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/approvals.json @@ -0,0 +1,188 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1784416935, + "digest": "8f27d753afdf6417ec9e2a7d6c3675f5ab1543c782f9666d6217a17243c55207", + "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784416950, + "digest": "97341e0660738d944c4427f0d6f51c3447b4e983137af18f85285bf8c7efc551", + "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784421258, + "digest": "42f2707b8da5339826601e49dcc1fc9e5ac9406f34cf96b99530932deb028a15", + "note": null + } + ], + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0018-aps-schema-self-describing-contract-endpoint-issue-32", + "actor": "0xLeif", + "reason": "Rebase of the schema endpoint branch onto current main changed delivery inputs; rebinding evidence", + "timestamp": 1784421240, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784416950, + "digest": "97341e0660738d944c4427f0d6f51c3447b4e983137af18f85285bf8c7efc551", + "note": null + }, + "prior_verification": { + "timestamp": 1784416945, + "commit": "8cf886df7ac87eac4234fc3fb8c13f46362e08d6", + "contract_digest": "8f27d753afdf6417ec9e2a7d6c3675f5ab1543c782f9666d6217a17243c55207", + "workspace_digest": "16fc131d9ca04eb1b0862f3869be796c9178ea858c48ee34cacd3d0d280d0969", + "acceptance_input_digest": "7d14a0fda636cb3766518e356d4d5172573dea4180d8179d2bac7876eca333be", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "8ab9c4daa8891ffc910d47bb87d4aacb1fd9846d1b476a834a7713bc8d4a142b", + "entry_digest": "34883ecd928da842000d76db4ecfda9f999210a18056d3938dd6bb7bbaa93ece", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "49773e106433a4ba793f1566037d73f3cee05352fcf4c19374679cc21be2d11f", + "entry_digest": "e33ad696886d992bb139f2a610e37bda628f54d5abc702b23da44f65b8192d6c", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "e42bc122f0295e008d477e43e4470c68442210b84b812dd2adbff1e1dfbc8037", + "entry_digest": "3738ea8715b7d9975d023a216df762f6105af0664039ae811a8341d2bcb6e5fb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "75d738cadcdeadef119f541765224f50c316fd3062b9a1605314ac7b28c5878e", + "entry_digest": "ce8231f473892a9153af6cfb213dcfb8057bbc73cfce6119745db3d3681a6d63", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/Schema.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "d113335331032b3ec1843dfefcca769797defa9e06dce41e7b6fccc5d1bbb62d", + "entry_digest": "7cc154a091f6445d8e3cc49a873c3b9b6d6773d47adfcb97cc9744108a51cda5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "b58592f7ed34723aef690d3d25de3bbf80bdb1007cb41030ec8ff42b6b4832c2", + "entry_digest": "f39ceb49be03abdad3e29ecb770e3a2462f9fa77c68f5d249a4c7098d4c259d3", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d0718b585b34ba44e1dd312783dea07884c745731d356cd1cfd92cdf0552b24e", + "entry_digest": "713f8ba159d4fe120da4c82396558540e53a4ed2670f600b4e21d8e078931e84", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d290147b8ef951ca7d5869654133c977c32b5a8c832a103e9bbcf0f119fcd65b", + "entry_digest": "b0b5544594a64ff8388b394471c5e28e69719e3489abbce745e93611e813e147", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f299b5b0fe35a9e1c65fc5d13b05e2cac7ac130dc86e94ab436b6818db9cb30a", + "entry_digest": "b23e62877a76ebeb4476e82c42cdde4a5e423eb62dadc9d13760104ac4e4903e", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-016" + ] + }, + "stale_acceptance_input_digest": "7d14a0fda636cb3766518e356d4d5172573dea4180d8179d2bac7876eca333be", + "current_acceptance_input_digest": "7f406d33405c7feccdb575bbe0bd5499f085756d5174a937e6ba876a6800a467" + } + ] +} diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/change.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/change.md new file mode 100644 index 0000000..edff2ac --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +state: accepted +type: feature +base_commit: 8cf886df7ac87eac4234fc3fb8c13f46362e08d6 +--- + +# Aps schema self-describing contract endpoint (issue 32) + +## Intent + +aps schema self-describing contract endpoint (issue 32) + +## Affected Canonical Specs + +- `aps-cli` + +## Acceptance Criteria + +- aps schema emits one cacheable JSON document: cliVersion (equals aps --version), integer schemaVersion (bumped on contract change), state-root precedence, every DemoKey with type/storage/lifetime/path, every subcommand with flags and payload refs, payload JSON shapes, and the stable error table with exit codes. Static contract only; tests assert coverage and validity; smoke greps schemaVersion and checks cliVersion equals --version. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/context.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/context.md new file mode 100644 index 0000000..1cae7d5 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/context.md @@ -0,0 +1,20 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: context +--- + +# Context + +Issue #32 (design decision 4). Agents need a self-describing endpoint to +discover the CLI contract and negotiate drift instead of guessing, and it +is the precondition for dynamic schema (1.0), where users define the keys. + +## Decisions + +- Static contract only; live state stays in `dump` (interview round 2). +- Integer `schemaVersion`, bumped on any contract change; agents compare + equality and bail on mismatch. Document must be cacheable. +- One `Schema.document()` builder deriving keys from `DemoKey` metadata; + commands/payloads/errors declared as static contract tables. +- Payload shapes use a minimal JSON Schema subset (type, properties, + required, array/items) instead of full draft 2020-12. diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/deltas/aps-cli.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/deltas/aps-cli.md new file mode 100644 index 0000000..3b29e6f --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/deltas/aps-cli.md @@ -0,0 +1,59 @@ +# aps-cli schema endpoint + +## MODIFIED + +### REQUIREMENT REQ-aps-cli-016 + +`profileName` SHALL read and write `ProfileDocument.name` through an AppState `Slice` over `profile`. + +Acceptance Criteria +- `aps set profileName X` updates the parent `profile` document name on disk. +- `aps get profileName` returns the current parent name field. +- `aps keys` lists `profileName` with storage `Slice`. + +### SPEC SECTION Purpose + +`aps` is a small Swift executable that dogfoods AppState outside SwiftUI. +It exposes a fixed demo schema through ArgumentParser subcommands so humans and +agents can get, set, watch, dump, list, and reset typed application state, and +it self-describes that contract for agents through the `schema` command. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum including `profile`, `secret`, and `profileName`. | +| `ProfileDocument` | Codable `{name, version}` FileState document. | +| `name` | ProfileDocument display name field. | +| `version` | ProfileDocument integer version field. | +| `init` | ProfileDocument memberwise initializer. | +| `APSKeychain` | Well-known Keychain service/account for `secret`. | +| `service` | Reverse-DNS Keychain feature namespace (`dev.leif.aps`). | +| `account` | Keychain account id (`secret`). | +| `secretAccount` | Full account key (`dev.leif.aps/secret`). | +| `APSError` | Typed CLI/domain errors. | +| `counter` | Int key stored in AppState `State`. | +| `message` | String key stored in AppState `State`. | +| `flag` | Bool key stored in AppState `StoredState`. | +| `note` | String key stored in AppState `FileState`. | +| `profile` | ProfileDocument key stored in AppState `FileState`. | +| `secret` | String key stored in AppState `SecureState` (Keychain). | +| `profileName` | String Slice over `ProfileDocument.name`. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk/Keychain-backed key did not persist after write. | +| `keychainUnavailable` | SecureState unavailable without Apple Security. | +| `corruptState` | FileState file exists but is undecodable (torn write). | +| `corruptStateExitCode` | Exit code 65 (`EX_DATAERR`) for `corruptState`. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState` / `SecureState` / `Slice`). | +| `valueType` | Human value type (`Int` / `String` / `Bool` / `ProfileDocument`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | + +Command tree (informational): `Aps` is the `@main` root with get, set, watch, +dump, keys, stats, reset, and schema. `schema` prints one cacheable JSON +document (`SchemaDocument`): cliVersion, integer schemaVersion (bumped on any +contract change), state-root precedence, keys, commands, payload shapes, and +the error table. Static contract only; live state stays in `dump`. +| `description` | Actionable error text for humans and ValidationError bridging. | diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/design.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/design.md new file mode 100644 index 0000000..b13b6bc --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/design.md @@ -0,0 +1,16 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: design +--- + +# Design + +- New `Sources/aps/Schema.swift`: `Schema.document()` returns a + `Document` (cliVersion, schemaVersion, stateRoot precedence, keys, + commands, payloads, errors) encoded via `CLIOutput.encodePretty`. +- New `schema` subcommand (`SchemaCmd`) prints the document; `--json` + accepted as a no-op for agent symmetry (same rule as `dump`). +- `cliVersion` is a constant asserted against `aps --version` in smoke, + guarding drift without refactoring the version declaration. +- Error table mirrors the APSError taxonomy (including `corrupt_state` + from the multi-writer work) as static content. diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/docs.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/docs.md new file mode 100644 index 0000000..2e8312f --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/docs.md @@ -0,0 +1,10 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: docs +--- + +# Docs + +README agent section: `aps schema` leads the example block and gets a +paragraph (schemaVersion drift rule, static-contract rule), plus a pointer +to ArgumentParser's `--experimental-dump-help` tree as the free complement. diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/plan.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/plan.md new file mode 100644 index 0000000..a583281 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/plan.md @@ -0,0 +1,14 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: plan +--- + +# Plan + +1. Add `Schema.swift` (document model + minimal JSON Schema node encoder). +2. Wire `SchemaCmd` into the command tree. +3. Tests: key/command coverage, error table stability, encoded validity. +4. Smoke: schemaVersion, key entries, error code, cliVersion equals + `--version`. +5. aps-cli spec (files list + informational command tree), requirements + (REQ-aps-cli-016), README agent section. diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/requirements.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/requirements.md new file mode 100644 index 0000000..5714a29 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/requirements.md @@ -0,0 +1,10 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: requirements +--- + +# Requirements + +- New REQ-aps-cli-016: `aps schema` emits one cacheable JSON contract + document (cliVersion, schemaVersion, state-root precedence, keys, + commands, payload shapes, error table); static contract only. diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/state.json b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/state.json new file mode 100644 index 0000000..6f13865 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/state.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "id": "CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32", + "slug": "aps-schema-self-describing-contract-endpoint-issue-32", + "title": "Aps schema self-describing contract endpoint (issue 32)", + "description": "aps schema self-describing contract endpoint (issue 32)", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "8cf886df7ac87eac4234fc3fb8c13f46362e08d6", + "created_at": 1784416844, + "updated_at": 1784421258, + "affected_specs": [ + "aps-cli" + ], + "affected_paths": [ + "Sources/aps/Schema.swift", + "Sources/aps/Aps.swift", + "Tests/apsTests/APSTests.swift", + "Scripts/smoke.sh", + "specs/aps-cli", + "README.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "aps schema emits one cacheable JSON document: cliVersion (equals aps --version), integer schemaVersion (bumped on contract change), state-root precedence, every DemoKey with type/storage/lifetime/path, every subcommand with flags and payload refs, payload JSON shapes, and the stable error table with exit codes. Static contract only; tests assert coverage and validity; smoke greps schemaVersion and checks cliVersion equals --version." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/tasks.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/tasks.md new file mode 100644 index 0000000..f7fb722 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/tasks.md @@ -0,0 +1,11 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: tasks +--- + +# Tasks + +- [x] Schema.swift document model and encoder +- [x] schema subcommand wired +- [x] Unit tests and smoke assertions +- [x] aps-cli spec, requirements, README updates diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/testing.md b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/testing.md new file mode 100644 index 0000000..6b82de1 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/testing.md @@ -0,0 +1,17 @@ +--- +change: CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32 +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +| --- | --- | +| REQ-aps-cli-016 | `testSchemaDocumentCoversAllKeysAndCommands`, `testSchemaErrorTableIsStable`, `testSchemaDocumentEncodesValidContractJSON`; smoke greps `schemaVersion`, key entries, `corrupt_state`, and asserts `cliVersion` equals `aps --version` | + +## Suites + +- `swift test` +- `fledge lanes run verify` diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification-attempts.json b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification-attempts.json new file mode 100644 index 0000000..d72b00e --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification-attempts.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784416945, + "commit": "8cf886df7ac87eac4234fc3fb8c13f46362e08d6", + "contract_digest": "8f27d753afdf6417ec9e2a7d6c3675f5ab1543c782f9666d6217a17243c55207", + "workspace_digest": "16fc131d9ca04eb1b0862f3869be796c9178ea858c48ee34cacd3d0d280d0969", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-016" + ] + }, + { + "timestamp": 1784421249, + "commit": "2a6b1c74f2af8e42875af964dbf2d6ad203f10e3", + "contract_digest": "8f27d753afdf6417ec9e2a7d6c3675f5ab1543c782f9666d6217a17243c55207", + "workspace_digest": "e7a7a6f43f0e03af1fef345f16f4a6c3ec79796161dcd49c936c5eeca3a660e9", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-016" + ] + } + ] +} diff --git a/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification.json b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification.json new file mode 100644 index 0000000..814a974 --- /dev/null +++ b/.specsync/changes/CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32/verification.json @@ -0,0 +1,143 @@ +{ + "timestamp": 1784421249, + "commit": "2a6b1c74f2af8e42875af964dbf2d6ad203f10e3", + "contract_digest": "8f27d753afdf6417ec9e2a7d6c3675f5ab1543c782f9666d6217a17243c55207", + "workspace_digest": "e7a7a6f43f0e03af1fef345f16f4a6c3ec79796161dcd49c936c5eeca3a660e9", + "acceptance_input_digest": "7f406d33405c7feccdb575bbe0bd5499f085756d5174a937e6ba876a6800a467", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "b991b810078e20e5f2df87a4ce21f55ad8f61a1053acc68e39ca6aefbca652c5", + "entry_digest": "f460155d0fe066b1f937456e96db2a7471df8f36b378873c56a88de9b830a5ac", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "825fc81ee7dd8426d1d973458eb275e847e275334b492ca62b7dbd3a8000bc0e", + "entry_digest": "bd1fc22be45d5644a7eea95f9652bd009f9ed8575f8d910b64fa1a8871c69773", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "e42bc122f0295e008d477e43e4470c68442210b84b812dd2adbff1e1dfbc8037", + "entry_digest": "3738ea8715b7d9975d023a216df762f6105af0664039ae811a8341d2bcb6e5fb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "75d738cadcdeadef119f541765224f50c316fd3062b9a1605314ac7b28c5878e", + "entry_digest": "ce8231f473892a9153af6cfb213dcfb8057bbc73cfce6119745db3d3681a6d63", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/Schema.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "d113335331032b3ec1843dfefcca769797defa9e06dce41e7b6fccc5d1bbb62d", + "entry_digest": "7cc154a091f6445d8e3cc49a873c3b9b6d6773d47adfcb97cc9744108a51cda5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dab10ac529548e31cf26a1d833af7d4be88b5f8a53de5854bad5ea768e10c7df", + "entry_digest": "646cf7f785139a1b5baf7631476f1caf7a8e53648b1155a62ad5ce859e82672f", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0e7acf1dbcfeeb11b1a75a2c58b3846a6853f3971106e1092aa6c20586864a8a", + "entry_digest": "5522914b8454b75db22a47858833959ff0788f72d20021d800d9b03d18f6ab7e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "a0bbd2da382c714a751ffc10690afac120a5711088254eb2a89f51965379e03d", + "entry_digest": "74ba459e869882763741c8dd52a3122129991586998ee9d2225bd4e907bcde21", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-016" + ] +} diff --git a/README.md b/README.md index 5010689..e4a681b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ swift run aps reset --all ### Agent usage ```bash +swift run aps schema # self-describing contract: keys, commands, payloads, errors swift run aps get note --json swift run aps set counter 3 --json swift run aps dump --json @@ -130,6 +131,8 @@ swift run aps set profileName agent --json swift run aps stats --json ``` +`aps schema` is the contract endpoint: one cacheable JSON document with `cliVersion`, integer `schemaVersion` (bumped on any contract change, so agents can detect drift), state-root precedence, every key and command, payload shapes, and the error-code table. It is static contract only; live state stays in `aps dump`. ArgumentParser's full command tree is also available as JSON via `aps --experimental-dump-help`. + `watch` uses Swift Observation for in-process updates and polls as a fallback so disk-backed `FileState` / `StoredState` changes can still surface, including updates written by another `aps` process. For `note` and `profile`, polling reads the JSON files directly so AppState's FileState cache cannot hide cross-process writes. ### Output modes (human and agent) diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index 692ea05..7ce7a0d 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -167,4 +167,15 @@ if grep -vq '^{' "$APS_HOME/watch.out"; then exit 1 fi +# aps schema emits the self-describing contract (compact JSON when piped). +"$bin" schema | grep -q '"schemaVersion":2' +"$bin" schema | grep -q '"name":"profile"' +"$bin" schema | grep -q '"name":"secret"' +"$bin" schema | grep -q '"code":"corrupt_state"' +"$bin" schema | grep -q '"code":"secret_unlock_failed"' +"$bin" schema | grep -q '"WatchEndEvent"' +CLI_VER="$("$bin" schema | sed -n 's/.*"cliVersion":"\([^"]*\)".*/\1/p' | head -1)" +test -n "$CLI_VER" +test "$CLI_VER" = "$("$bin" --version)" + echo "smoke ok" diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift index 46c926b..6c912a0 100644 --- a/Sources/aps/Aps.swift +++ b/Sources/aps/Aps.swift @@ -29,12 +29,30 @@ struct Aps: ParsableCommand { Dump.self, Keys.self, Stats.self, - Reset.self + Reset.self, + SchemaCmd.self ], defaultSubcommand: nil ) } +extension Aps { + struct SchemaCmd: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "schema", + abstract: "Print the self-describing CLI contract (keys, commands, payloads, errors) as JSON." + ) + + @Flag(name: .long, help: "Emit machine-readable JSON (accepted for symmetry; schema is always JSON).") + var json: Bool = false + + func run() throws { + _ = json + print(try CLIOutput.encodeJSON(Schema.document())) + } + } +} + extension Aps { struct Get: ParsableCommand { static let configuration = CommandConfiguration( diff --git a/Sources/aps/Schema.swift b/Sources/aps/Schema.swift new file mode 100644 index 0000000..4205cec --- /dev/null +++ b/Sources/aps/Schema.swift @@ -0,0 +1,329 @@ +import Foundation + +/// `aps schema`: the self-describing contract endpoint (issue #32). +/// +/// Static contract only: one cacheable JSON document describing the CLI +/// version, schema version, keys, commands, payload shapes, state-root +/// precedence, and the error table. Live state stays in `dump`. +/// `schemaVersion` is an integer bumped on any contract change; agents +/// compare equality and bail on mismatch. +enum Schema { + + static let cliVersion = "0.2.0" + static let schemaVersion = 2 + + // MARK: - Document model + + struct Document: Encodable { + let cliVersion: String + let schemaVersion: Int + let stateRoot: StateRootDoc + let keys: [KeyEntry] + let commands: [CommandEntry] + let payloads: [String: Node] + let errors: [ErrorEntry] + } + + struct StateRootDoc: Encodable { + let precedence: [String] + let env: String + let flag: String + let defaultPath: String + } + + struct KeyEntry: Encodable { + let name: String + let type: String + let storage: String + let lifetime: String + let path: String? + let keychainAccount: String? + } + + struct CommandEntry: Encodable { + let name: String + let summary: String + let arguments: [String] + let flags: [String] + let payload: String? + let streaming: Bool + } + + struct ErrorEntry: Encodable { + let code: String + let exitCode: Int + let meaning: String + let hint: String + } + + // MARK: - Minimal JSON Schema subset (type/object/properties/required/array/items) + + struct Property: Encodable { + let name: String + let node: Node + let required: Bool + } + + indirect enum Node: Encodable { + case prim(String) + case obj([Property]) + case arr(Node) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .prim(let type): + try container.encode(type, forKey: .type) + case .obj(let properties): + try container.encode("object", forKey: .type) + var props: [String: Node] = [:] + var required: [String] = [] + for property in properties { + props[property.name] = property.node + if property.required { required.append(property.name) } + } + try container.encode(props, forKey: .properties) + try container.encode(required, forKey: .required) + case .arr(let item): + try container.encode("array", forKey: .type) + try container.encode(item, forKey: .items) + } + } + + private enum CodingKeys: String, CodingKey { + case type, properties, required, items + } + } + + // MARK: - Builders + + private static func prop(_ name: String, _ node: Node, required: Bool = true) -> Property { + Property(name: name, node: node, required: required) + } + + static func document() -> Document { + Document( + cliVersion: cliVersion, + schemaVersion: schemaVersion, + stateRoot: StateRootDoc( + precedence: ["--state-dir", "APS_HOME", "~/.aps"], + env: "APS_HOME", + flag: "--state-dir", + defaultPath: "~/.aps" + ), + keys: keyEntries(), + commands: commandEntries(), + payloads: payloadNodes(), + errors: errorEntries() + ) + } + + private static func keyEntries() -> [KeyEntry] { + DemoKey.allCases.map { key in + let lifetime: String + let path: String? + let account: String? + switch key { + case .counter, .message: + lifetime = "process" + path = nil + account = nil + case .flag: + lifetime = "persisted" + path = "UserDefaults (aps.flag)" + account = nil + case .note: + lifetime = "persisted" + path = "/note.json" + account = nil + case .profile: + lifetime = "persisted" + path = "/profile.json" + account = nil + case .secret: + lifetime = "persisted" + path = "/secret.enc" + account = nil + case .profileName: + lifetime = "persisted (slice of profile)" + path = "/profile.json" + account = nil + } + return KeyEntry( + name: key.rawValue, + type: key.valueType, + storage: key.storage, + lifetime: lifetime, + path: path, + keychainAccount: account + ) + } + } + + private static func commandEntries() -> [CommandEntry] { + [ + CommandEntry( + name: "get", + summary: "Print the current value for a key.", + arguments: [""], + flags: ["--json", "--state-dir"], + payload: "KeyValuePayload", + streaming: false + ), + CommandEntry( + name: "set", + summary: "Set a key to a value.", + arguments: ["", ""], + flags: ["--json", "--state-dir"], + payload: "KeyValuePayload", + streaming: false + ), + CommandEntry( + name: "watch", + summary: "Print the value whenever it changes.", + arguments: [""], + flags: ["--interval", "--count", "--timeout", "--jsonl", "--state-dir"], + payload: "WatchEvent", + streaming: true + ), + CommandEntry( + name: "dump", + summary: "Print all known keys as pretty JSON.", + arguments: [], + flags: ["--json", "--state-dir"], + payload: "DumpSnapshot", + streaming: false + ), + CommandEntry( + name: "keys", + summary: "List the fixed keys and how they are stored.", + arguments: [], + flags: ["--json"], + payload: "KeysPayload", + streaming: false + ), + CommandEntry( + name: "reset", + summary: "Reset one key (or --all) back to its initial value.", + arguments: ["[]"], + flags: ["--all", "--json", "--state-dir"], + payload: "ResetPayload", + streaming: false + ), + CommandEntry( + name: "stats", + summary: "Print process-local mutation stats.", + arguments: [], + flags: ["--watch", "--count", "--timeout", "--interval", "--json"], + payload: "StatsPayload", + streaming: true + ), + CommandEntry( + name: "schema", + summary: "Print this self-describing contract document.", + arguments: [], + flags: ["--json"], + payload: "SchemaDocument", + streaming: false + ), + ] + } + + private static func payloadNodes() -> [String: Node] { + let jsonValue = Node.prim("string | integer | boolean | object") + return [ + "KeyValuePayload": .obj([ + prop("key", .prim("string")), + prop("type", .prim("string")), + prop("storage", .prim("string")), + prop("value", jsonValue), + ]), + "KeysPayload": .obj([ + prop("keys", .arr(.obj([ + prop("key", .prim("string")), + prop("type", .prim("string")), + prop("storage", .prim("string")), + prop("detail", .prim("string")), + ]))), + ]), + "WatchEvent": .obj([ + prop("key", .prim("string")), + prop("type", .prim("string")), + prop("storage", .prim("string")), + prop("value", jsonValue), + prop("timestamp", .prim("string (ISO-8601)")), + ]), + "WatchErrorEvent": .obj([ + prop("type", .prim("string (\"error\")")), + prop("key", .prim("string")), + prop("error", .prim("string")), + prop("message", .prim("string")), + prop("timestamp", .prim("string (ISO-8601)")), + ]), + "WatchEndEvent": .obj([ + prop("type", .prim("string (\"end\")")), + prop("key", .prim("string")), + prop("reason", .prim("string (\"count\" | \"timeout\" | \"sigint\" | \"sigterm\" | \"signal\")")), + prop("timestamp", .prim("string (ISO-8601)")), + ]), + "ResetPayload": .obj([ + prop("reset", .prim("string (\"all\" | \"key\")")), + prop("key", .prim("string?"), required: false), + prop("value", jsonValue, required: false), + ]), + "StatsPayload": .obj([ + prop("mutationCount", .prim("integer")), + prop("lastMutatedKey", .prim("string")), + prop("storage", .prim("string")), + ]), + "ErrorEnvelope": .obj([ + prop("error", .obj([ + prop("code", .prim("string")), + prop("message", .prim("string")), + prop("hint", .prim("string")), + ])), + ]), + ] + } + + private static func errorEntries() -> [ErrorEntry] { + [ + ErrorEntry( + code: "invalid_value", + exitCode: 64, + meaning: "caller-fixable input: value does not parse for the key type", + hint: "Run `aps keys` to see expected types per key." + ), + ErrorEntry( + code: "decoding_failed", + exitCode: 65, + meaning: "a value or file is not valid JSON for its key", + hint: "Check the input or the state root (--state-dir / APS_HOME)." + ), + ErrorEntry( + code: "corrupt_state", + exitCode: 65, + meaning: "a state file exists but is undecodable (torn write)", + hint: "Reset the key or repair the file under the state root." + ), + ErrorEntry( + code: "secret_unlock_failed", + exitCode: 69, + meaning: "encrypted secret could not be unlocked (wrong passphrase or key)", + hint: "Check APS_SECRET_PASSPHRASE or the secret.key file under the state root." + ), + ErrorEntry( + code: "encoding_failed", + exitCode: 70, + meaning: "internal bug: value could not be JSON-encoded", + hint: "Please report this if it reproduces." + ), + ErrorEntry( + code: "persistence_failed", + exitCode: 73, + meaning: "write did not persist (unwritable state root)", + hint: "Check that the state root exists and is writable." + ), + ] + } +} diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift index 9848d12..1684b0a 100644 --- a/Tests/apsTests/APSTests.swift +++ b/Tests/apsTests/APSTests.swift @@ -851,4 +851,56 @@ final class APSTests: XCTestCase { XCTAssertTrue(line.contains(#""reason":"timeout""#)) XCTAssertTrue(line.contains(#""key":"counter""#)) } + + func testSchemaDocumentCoversAllKeysAndCommands() throws { + let document = Schema.document() + + XCTAssertEqual(document.schemaVersion, 2) + XCTAssertEqual(document.cliVersion, "0.2.0") + XCTAssertEqual(document.keys.map(\.name), DemoKey.allCases.map(\.rawValue)) + XCTAssertEqual(document.stateRoot.precedence, ["--state-dir", "APS_HOME", "~/.aps"]) + + let commandNames = document.commands.map(\.name) + for expected in ["get", "set", "watch", "dump", "keys", "reset", "stats", "schema"] { + XCTAssertTrue(commandNames.contains(expected), "missing command \(expected)") + } + + let secret = document.keys.first { $0.name == "secret" } + XCTAssertEqual(secret?.path, "/secret.enc") + XCTAssertNil(secret?.keychainAccount) + let note = document.keys.first { $0.name == "note" } + XCTAssertEqual(note?.path, "/note.json") + } + + func testSchemaErrorTableIsStable() { + let table = Schema.document().errors + XCTAssertEqual(table.count, 6) + let byCode = Dictionary(uniqueKeysWithValues: table.map { ($0.code, $0.exitCode) }) + XCTAssertEqual(byCode["invalid_value"], 64) + XCTAssertEqual(byCode["decoding_failed"], 65) + XCTAssertEqual(byCode["corrupt_state"], 65) + XCTAssertEqual(byCode["secret_unlock_failed"], 69) + XCTAssertEqual(byCode["encoding_failed"], 70) + XCTAssertEqual(byCode["persistence_failed"], 73) + for entry in table { + XCTAssertFalse(entry.hint.isEmpty, "hint required for \(entry.code)") + XCTAssertFalse(entry.meaning.isEmpty, "meaning required for \(entry.code)") + } + } + + func testSchemaDocumentEncodesValidContractJSON() throws { + let json = try CLIOutput.encodePretty(Schema.document()) + let data = try XCTUnwrap(json.data(using: .utf8)) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(object?["schemaVersion"] as? Int, 2) + let payloads = try XCTUnwrap(object?["payloads"] as? [String: Any]) + for name in ["KeyValuePayload", "KeysPayload", "WatchEvent", "WatchErrorEvent", "WatchEndEvent", "ResetPayload", "StatsPayload", "ErrorEnvelope"] { + XCTAssertNotNil(payloads[name], "missing payload schema \(name)") + } + let event = try XCTUnwrap(payloads["WatchEvent"] as? [String: Any]) + XCTAssertEqual(event["type"] as? String, "object") + XCTAssertNotNil(event["properties"]) + XCTAssertNotNil(event["required"]) + } } diff --git a/specs/aps-cli/aps-cli.spec.md b/specs/aps-cli/aps-cli.spec.md index d229c8e..ba29986 100644 --- a/specs/aps-cli/aps-cli.spec.md +++ b/specs/aps-cli/aps-cli.spec.md @@ -1,6 +1,6 @@ --- module: aps-cli -version: 23 +version: 24 status: active files: - Sources/aps/Aps.swift @@ -9,6 +9,7 @@ files: - Sources/aps/TTY.swift - Sources/aps/WatchTermination.swift - Sources/aps/SecretStore.swift + - Sources/aps/Schema.swift db_tables: [] depends_on: - state-store @@ -20,7 +21,8 @@ depends_on: `aps` is a small Swift executable that dogfoods AppState outside SwiftUI. It exposes a fixed demo schema through ArgumentParser subcommands so humans and -agents can get, set, watch, dump, list, and reset typed application state. +agents can get, set, watch, dump, list, and reset typed application state, and +it self-describes that contract for agents through the `schema` command. ## Public API @@ -65,6 +67,12 @@ Command output modes (informational): human output is TTY-aware (aligned is byte-stable plain text. JSON is pretty on TTY and compact when piped (gh rule). `watch --json` is an alias for `--jsonl`; `keys --quiet` prints key names only. Machine shapes are additive-only contracts; human text may evolve. +Command tree (informational): `Aps` is the `@main` root with get, set, watch, +dump, keys, stats, reset, and schema. `schema` prints one cacheable JSON +document (`SchemaDocument`): cliVersion, integer schemaVersion (bumped on any +contract change), state-root precedence, keys, commands, payload shapes, and +the error table. Static contract only; live state stays in `dump`. +| `description` | Actionable error text for humans and ValidationError bridging. | ## Invariants @@ -172,3 +180,4 @@ Exit codes (sysexits-aligned): | 2026-07-19 | CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33: TTY-aware output under the git porcelain rule (issue 33) | | 2026-07-19 | CHG-0023-watch-signal-handling-and-termination-semantics-issue-34: Watch signal handling and termination semantics (issue 34) | | 2026-07-19 | CHG-0024-encrypted-file-secret-store-via-swift-crypto-issue-35: Encrypted-file secret store via swift-crypto (issue 35) | +| 2026-07-19 | CHG-0025-aps-schema-self-describing-contract-endpoint-issue-32: Add aps schema self-describing contract endpoint (issue 32) | diff --git a/specs/aps-cli/requirements.md b/specs/aps-cli/requirements.md index f5f0ec3..8f8260e 100644 --- a/specs/aps-cli/requirements.md +++ b/specs/aps-cli/requirements.md @@ -136,3 +136,12 @@ Acceptance Criteria - Wrong passphrase fails with `APSError.secretUnlockFailed`; corrupt envelope fails with `APSError.decodingFailed`. - Passphrase entry is env-var based; an optional TTY getpass prompt exists when `APS_SECRET_USE_PASSPHRASE=1`. +### REQ-aps-cli-016 + +`aps schema` SHALL emit one cacheable JSON document describing the CLI contract: cliVersion, integer schemaVersion (bumped on any contract change), state-root precedence, keys, commands, payload shapes, and the error table. + +Acceptance Criteria +- Output is valid JSON with a top-level integer `schemaVersion`. +- Keys cover every `DemoKey`; commands cover every subcommand; the error table lists every stable code with its exit code. +- `cliVersion` equals `aps --version`. +- The document is static contract only; live state stays in `dump`.