diff --git a/.attest.json b/.attest.json new file mode 100644 index 0000000..2ebd680 --- /dev/null +++ b/.attest.json @@ -0,0 +1,6 @@ +{ + "requireAttestation": false, + "requireTestsPassed": false, + "requireSignature": false, + "requireHumanApprovalWhenVerdictAtLeast": "block" +} diff --git a/.augur.toml b/.augur.toml new file mode 100644 index 0000000..031b459 --- /dev/null +++ b/.augur.toml @@ -0,0 +1,3 @@ +[thresholds] +review = 35 +block = 65 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c7100f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +# Private repo: run on macOS self-hosted runners (not GitHub-hosted). +# Revisit before making the repository public: fork PRs must not run on +# self-hosted hosts. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + build-test-smoke: + runs-on: [self-hosted, macOS] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Swift version + run: swift --version + - name: Build (release) + run: swift build -c release + - name: Test + run: swift test + - name: Smoke CLI + env: + APS_BIN: .build/release/aps + run: ./Scripts/smoke.sh diff --git a/.github/workflows/trust.yml b/.github/workflows/trust.yml new file mode 100644 index 0000000..894c909 --- /dev/null +++ b/.github/workflows/trust.yml @@ -0,0 +1,77 @@ +name: Trust + +# Private repo: CorvidLabs trust gate on macOS self-hosted runners. +# SpecSync pin is 5.1.1 via Trust v1.0.1 + a runner-temp file:// mirror +# (non-default SpecSync versions cannot use the remote GitHub download path). +# Revisit before making the repository public: fork PRs must not run on +# self-hosted hosts. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + trust: + runs-on: [self-hosted, macOS] + timeout-minutes: 45 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.1.6 + with: + fetch-depth: 0 + + - name: Fetch attest notes + run: git fetch origin "+refs/notes/attest:refs/notes/attest" 2>/dev/null || true + + # actions/checkout embeds a token in origin; Trust then treats the worktree + # as external and refuses event-derived ranges. Pass the canonical range + # explicitly while keeping credentials for private-repo note fetches. + - name: Resolve Trust comparison range + id: trust_range + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "range=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + elif [ -z "${{ github.event.before }}" ] || [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + empty="$(git hash-object -t tree /dev/null)" + echo "range=${empty}..${{ github.sha }}" >> "$GITHUB_OUTPUT" + else + echo "range=${{ github.event.before }}..${{ github.sha }}" >> "$GITHUB_OUTPUT" + fi + + # Trust rejects remote download overrides for non-default SpecSync versions. + # Stage the 5.1.1 release archives under RUNNER_TEMP for an authority-free + # file:// mirror (platform archive + adjacent .sha256). + - name: Prepare SpecSync 5.1.1 local mirror + id: specsync_mirror + run: | + set -euo pipefail + VERSION="5.1.1" + MIRROR="${RUNNER_TEMP}/specsync-${VERSION}-mirror" + mkdir -p "$MIRROR" + BASE="https://github.com/CorvidLabs/spec-sync/releases/download/v${VERSION}" + for asset in \ + specsync-macos-aarch64.tar.gz \ + specsync-macos-x86_64.tar.gz + do + curl -fsSL "${BASE}/${asset}" -o "${MIRROR}/${asset}" + curl -fsSL "${BASE}/${asset}.sha256" -o "${MIRROR}/${asset}.sha256" + (cd "$MIRROR" && shasum -a 256 -c "${asset}.sha256") + done + echo "path=${MIRROR}" >> "$GITHUB_OUTPUT" + + - name: CorvidLabs Trust gate + id: trust + uses: CorvidLabs/trust@c8f969a12209575877b5eca1ec557431621381e0 # v1.0.1 + with: + range: ${{ steps.trust_range.outputs.range }} + specsync-version: "5.1.1" + specsync-download-base-url: file://${{ steps.specsync_mirror.outputs.path }} + + - name: Check managed agent rules + run: | + grep -q "CorvidLabs trust toolchain: BEGIN" AGENTS.md + grep -q "CorvidLabs trust toolchain: END" AGENTS.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ecb30a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +/.build +/.swiftpm +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +*.swp +*~ + +# CorvidLabs trust toolchain +augur.json diff --git a/.specsync/.gitignore b/.specsync/.gitignore new file mode 100644 index 0000000..6c6df34 --- /dev/null +++ b/.specsync/.gitignore @@ -0,0 +1,5 @@ +cache/ +change.lock +hashes.json +*.local +# Local SpecSync caches / working files diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json new file mode 100644 index 0000000..5ace796 --- /dev/null +++ b/.specsync/change-sequence.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "sequence": 2, + "id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "acknowledged_collisions": [] +} diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json new file mode 100644 index 0000000..4478639 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json @@ -0,0 +1,927 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784396091, + "digest": "75056184138803c84d85114b485bbbc424c280e52b024d9840dc23f56ace7142", + "note": "Approve FileState watch cache and path isolation fixes from PR review." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784396094, + "digest": "13f79887f3aa6c765de95da8a27321fefdc3132474a4f8d91216673760c1a0b9", + "note": "Accept review fixes for watch note cross-process and path isolation." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784396148, + "digest": "75056184138803c84d85114b485bbbc424c280e52b024d9840dc23f56ace7142", + "note": "Re-approve CHG-0002 after evidence refresh." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784396151, + "digest": "2b6e60409c76df712a85a03938ec3d5108f9653a5c7b66ed0a65b8d98951819d", + "note": "Refresh CHG-0002 evidence under SpecSync 5.0.1." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784396248, + "digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "note": "Approve expanded full-PR path coverage with FileState review fixes." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784396251, + "digest": "d7d13c1e90666f773314a1536e8699b7010e5dab2d59bb16f6d6572da779e75f", + "note": "Accept review fixes as sole active SpecSync change." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784397142, + "digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "note": "Re-approve after SpecSync 5.1.1 pin; no semantic contract definition change." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784397150, + "digest": "bbc61826d284f74a887be499160214984e401f31a7fb1d78ea2464201cad270c", + "note": "Accepted under SpecSync 5.1.1; incomplete 5.0.1 manual archive removed." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784397197, + "digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "note": "Re-approve after Trust v1.0.1 + SpecSync 5.1.1 mirror wiring; no semantic contract definition change." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784397204, + "digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "note": "Re-approve after Trust v1.0.1 + SpecSync 5.1.1 mirror wiring; no semantic contract definition change." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784397208, + "digest": "2a023c4ceb638ee95b162ef18367cb5338e75124bee1a189fbc6aab47e30bdd0", + "note": "Accepted SpecSync 5.1.1 project pin with Trust v1.0.1 CI mirror; incomplete 5.0.1 manual archive removed." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784397241, + "digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "note": "Re-approve after README SpecSync 5.1.1 note; no semantic contract definition change." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784397245, + "digest": "c19af25e22655b87941f13b433d7678274684e8ac449beb22277aee2c5702224", + "note": "Accepted with SpecSync 5.1.1 documented for local brew parity." + } + ], + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "actor": "leif algo", + "reason": "Bump project SpecSync pin to 5.1.1 after removing incomplete 5.0.1 manual archive that lacked legacy-baseline.json; rebind evidence under latest SpecSync.", + "timestamp": 1784397141, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784396251, + "digest": "d7d13c1e90666f773314a1536e8699b7010e5dab2d59bb16f6d6572da779e75f", + "note": "Accept review fixes as sole active SpecSync change." + }, + "prior_verification": { + "timestamp": 1784396251, + "commit": "c6d9326fd7f4c7925deb5ff4f27f984a073bf6b8", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "453dec7743a2e93811de583cf817d2ecdaf85a6ee3b315e5a4966dfe5eb2feec", + "acceptance_input_digest": "a0ecbb0b17e619708358399ba25760a9638101183e4eea783bd7c2d46cf56f48", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + "stale_acceptance_input_digest": "a0ecbb0b17e619708358399ba25760a9638101183e4eea783bd7c2d46cf56f48", + "current_acceptance_input_digest": "8fed4e15ddf1fb73057dd1fcda89f6464e0f7ae15c00ec62a323561a147ce96a" + }, + { + "schema_version": 1, + "change_id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "actor": "leif algo", + "reason": "Align Trust CI to SpecSync 5.1.1 via Trust v1.0.1 and a runner-temp file:// mirror; keep project pin and CI on the same latest SpecSync.", + "timestamp": 1784397197, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784397150, + "digest": "bbc61826d284f74a887be499160214984e401f31a7fb1d78ea2464201cad270c", + "note": "Accepted under SpecSync 5.1.1; incomplete 5.0.1 manual archive removed." + }, + "prior_verification": { + "timestamp": 1784397145, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "00103adb8e10e5ba2e183296a6112b25b23b3554206ea7c9a63ed916cb55fbda", + "acceptance_input_digest": "1588bf2a9bea098ca85356f3c17279de5e2703ae5dee34fc79c5d08dba7e2eaa", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".attest.json", + "kind": "file", + "mode": 33188, + "payload_digest": "fda17571040ce10c378e009859f92cc33afd67b5c62d3f96510bf9e6e5de0a9f", + "entry_digest": "9d185be1f08f917f1162191bbd90ca325f047bec1cda66f2fc6d0737c08da1ab", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".augur.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "17f6ad5b84b4db012f0e3f398a96a478c1324fd47e72e1dd2a13c7cf9a432983", + "entry_digest": "bac13c952da05292bdd0af5849aad9d9def3ae5afc830fa42b6914b2fd874beb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/ci.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1f310d5d11b4466aedf85aac75d7db13b07d564f1e38e2f596defb8cf2f06d1c", + "entry_digest": "9abaefb404d6bb884367be7ea7524e890c9bab326c810c0985569c35a83418e7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/trust.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "2466ee51b03676c27a0a0510834e4d2083c017eceee28b223a63665a4e8bb144", + "entry_digest": "51ee76e8a5529942adfd73f19e5dea39fec7de68b18c9cb5773aa28ada4f8789", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "30d23b531c3fcb3e46c413c3d06d3c2848bb273aaf86abdee660e201ed9519a9", + "entry_digest": "8c2aa3eee1fa32d85bddb1f5daf626a0a97ce920f434cd2ec57050fe1623867a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "3f711fd3f671890cacd293a97ff33223806fce0ce831a54dbd4aad1eeba79433", + "entry_digest": "ae757f4ebb20a9690f0644cc138231711296318c7beafb7108d256d35cca3797", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".trust.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "6284ced27613c8e2dd8266ffd1668f021cb24e1edb6ccd79c9ade86032c09ce8", + "entry_digest": "7cdd9144620ced58d9ac48ee7d2b8563f5564f1ae2db02a22e7eed781963257e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "AGENTS.md", + "kind": "file", + "mode": 33188, + "payload_digest": "a671dd50eea50f115e6f9abd554e5a8ad7e520208fec5c4bd540c4f3a07ef807", + "entry_digest": "c321960d8ebbf18c2833c8f8b7fe413d6f0e349b424bf2b296a4da7a26316750", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "CLAUDE.md", + "kind": "file", + "mode": 33188, + "payload_digest": "e2df5028858a540821e4a666297d536a14bf598d48afe6c44df921b0f1ddbf32", + "entry_digest": "6930540d6a42ffd8c849bdf10eaf69c3f231cf5e824c9e6f0e694d3edb91bda3", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.resolved", + "kind": "file", + "mode": 33188, + "payload_digest": "12eea9fa129e151eaffdc230a234293407f0e3a99c52f15597c72e4e591c0e1e", + "entry_digest": "554d48ebe132641edab54e1d89d163cc2534521a9f41750ed5bd8582723a2d28", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "26c8955179127e90c5b2750191b7ffa99d5d6a0c43b82a93fd3731334426e8e1", + "entry_digest": "0b185dd498f42e05a6cc8c2447d547432f4c40000c5dc26f17fe0fc7ae21d6cb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "5fc2d0e776b093053868b00233b9027cbdefd62fb5a0fbb94a802ee59c3101b2", + "entry_digest": "3a2de19f367679d830884d903ac4b0f922c2493928c29e132c33aa4f8c781d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "5947d02b95d6205d63beca54876398d81226d0d4796b4234b646a3b919f08b4c", + "entry_digest": "a847aedf94fa22e35460140f14bdd4be567c550ddc2d20ade0f672329f3efbd7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "68f03f7ec7a10f052e147d57faf5e359e67b7582e06586078b72de53972071b8", + "entry_digest": "e6d8d515e176c6da445f7a402edbabea6e28bacaf74695db6154dcb7c56bced0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoKey.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae5452ba9738ccd90f004a65fcc64f9beeb4366dc8ab6a094be70db2356bc68b", + "entry_digest": "bab68a6071d00c323405116a9ff4380356c8477755850bbc124233ef6c667141", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoState.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "741c2c25af9d89a71e3831cbcec0e7b63efb982caa4c83ae7c7ab31dfbf93924", + "entry_digest": "883c29aefe922beb4e56beb2d3bae3d7ffd5047c05ee3453bbac9dcdfa6483a3", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "635340259dcd618bf5099e07dad823f72f4517f6a2762ec1e6b67965221ccef2", + "entry_digest": "637ab3fca33111ebb5f145cdd632e7a6412e99d88d08e3890ab1cc3edc54c736", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "1eec78c3da61ce03c9d9e0dcfa73b5a946fd9486446566f44f52ff568b66b742", + "entry_digest": "8632b57bb371b7e22f6e0991082590e89e53c29a2a08e5cb28ddb1e6cbca1375", + "owners": [ + "state-store" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "fc62708d2a2672bbcd27c3a75eb9a41005ae2d6d07cc657ba692e1839d114499", + "entry_digest": "529fbe2c459f55c31654c89244ea030feda05557fab79fda215fe41ed4755573", + "owners": [ + "@exact:test" + ] + }, + { + "path": "fledge.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "f69feb965265eb9481197160885409a03d0eb367149a2955c7b17a98dee9ba4e", + "entry_digest": "4994cce91eb6ed15dcf039ff4ce7194b901fe8e5ef33905503817173f42b6560", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "fc2f157fdcb2bc927a7ad41187993690389b2c9a71f4fba6077bcace0b84c1d2", + "entry_digest": "b025cb47565ae0dad36c2adf59145aa898d88782df84e8805489ac76abcbb787", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6a67354ec782d88510bea4b0ec28e1b1a3c50fc01e625ee32d6783f4dfbc86e0", + "entry_digest": "9cb95bb0d687799ba10637230b404cbef8a5abfb45d46a07a7bb50bee9703431", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "99f67a96136a4f15785230b61b5c74d57ccac1ad05877513097ff0280de510da", + "entry_digest": "c0c5fe71d6b614fe5e90a5c3ebba3b173a7253255b90c9748144d96ff366915e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6f0c8e3aa90f43e5dc40389e77088b2ec6ebb725c89c57217970fc1c221f527", + "entry_digest": "af31087c96d633274df81aecd4e99f520a55995882545190d2d42bb30fa5c3aa", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "14420af80f34d7954894aaf63f9864db13eea74549796043f54badf82805394a", + "entry_digest": "1227fad23fad49d312453dfe8fffc2bfe67396fdb29c88dab355bd60e8ec91b5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ddb4564d168db43eadd6315a7f56b532198905a79abebfee77db890db0f9c402", + "entry_digest": "a14edf94507dfd13d7d325bf68e370e0c1646e323b28341bd0c1dfff72f05d88", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "dfa974ed7b0c6488ba70d0f6cac9f574c4df29674612abeef1ec12dd89f8241a", + "entry_digest": "75cc7a8c3022b64210eab987dd93f655fb812deb45b7f79d064456e4539b6157", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "22e65ea3a60cd05e76d0450c8136609bd06257eb70a5ac624b8ba25ff4337cba", + "entry_digest": "010f681da95cc42be632e117cc6b41f2fa4abd33a62a30400a1c583479a65369", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "711db2a24a4959531ca966ac43d96e7817788ebe74077280a6cff950cb9a8779", + "entry_digest": "8402318068cd8ab6f8a596519289e70425dbc28bf6b31d5c1ef6f0a17f927f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "96fbc44cdbe25dfab52a47f8efdbd4698203571981b181c78a04769112402f1d", + "entry_digest": "8b0abd2a0f6613b9caa1f2831cdf8776b80a1945552eade241ff3d3b8d6404af", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + "stale_acceptance_input_digest": "1588bf2a9bea098ca85356f3c17279de5e2703ae5dee34fc79c5d08dba7e2eaa", + "current_acceptance_input_digest": "b4503835c81ef2983e6deee01231adb7c39f133c1c4b3fbd7d149a09ac6f7c60" + }, + { + "schema_version": 1, + "change_id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "actor": "leif algo", + "reason": "Document SpecSync 5.1.1 local pin in README after Trust CI mirror wiring.", + "timestamp": 1784397241, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784397208, + "digest": "2a023c4ceb638ee95b162ef18367cb5338e75124bee1a189fbc6aab47e30bdd0", + "note": "Accepted SpecSync 5.1.1 project pin with Trust v1.0.1 CI mirror; incomplete 5.0.1 manual archive removed." + }, + "prior_verification": { + "timestamp": 1784397207, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "cfcca5e750e6b8e46ae8df74f0d7ffa9133287c5a38c8437f74e8c129303a676", + "acceptance_input_digest": "b4503835c81ef2983e6deee01231adb7c39f133c1c4b3fbd7d149a09ac6f7c60", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".attest.json", + "kind": "file", + "mode": 33188, + "payload_digest": "fda17571040ce10c378e009859f92cc33afd67b5c62d3f96510bf9e6e5de0a9f", + "entry_digest": "9d185be1f08f917f1162191bbd90ca325f047bec1cda66f2fc6d0737c08da1ab", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".augur.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "17f6ad5b84b4db012f0e3f398a96a478c1324fd47e72e1dd2a13c7cf9a432983", + "entry_digest": "bac13c952da05292bdd0af5849aad9d9def3ae5afc830fa42b6914b2fd874beb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/ci.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1f310d5d11b4466aedf85aac75d7db13b07d564f1e38e2f596defb8cf2f06d1c", + "entry_digest": "9abaefb404d6bb884367be7ea7524e890c9bab326c810c0985569c35a83418e7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/trust.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "e165929994150d856d05ad048d8e1032e09df989c78ea3f1390d4e885b50af56", + "entry_digest": "e405b6225fdb502cfae9b22a9c14e21be8d1715bbaf8d56cdfd178bb8e173cb9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "30d23b531c3fcb3e46c413c3d06d3c2848bb273aaf86abdee660e201ed9519a9", + "entry_digest": "8c2aa3eee1fa32d85bddb1f5daf626a0a97ce920f434cd2ec57050fe1623867a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "3f711fd3f671890cacd293a97ff33223806fce0ce831a54dbd4aad1eeba79433", + "entry_digest": "ae757f4ebb20a9690f0644cc138231711296318c7beafb7108d256d35cca3797", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".trust.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "6284ced27613c8e2dd8266ffd1668f021cb24e1edb6ccd79c9ade86032c09ce8", + "entry_digest": "7cdd9144620ced58d9ac48ee7d2b8563f5564f1ae2db02a22e7eed781963257e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "AGENTS.md", + "kind": "file", + "mode": 33188, + "payload_digest": "a671dd50eea50f115e6f9abd554e5a8ad7e520208fec5c4bd540c4f3a07ef807", + "entry_digest": "c321960d8ebbf18c2833c8f8b7fe413d6f0e349b424bf2b296a4da7a26316750", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "CLAUDE.md", + "kind": "file", + "mode": 33188, + "payload_digest": "e2df5028858a540821e4a666297d536a14bf598d48afe6c44df921b0f1ddbf32", + "entry_digest": "6930540d6a42ffd8c849bdf10eaf69c3f231cf5e824c9e6f0e694d3edb91bda3", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.resolved", + "kind": "file", + "mode": 33188, + "payload_digest": "12eea9fa129e151eaffdc230a234293407f0e3a99c52f15597c72e4e591c0e1e", + "entry_digest": "554d48ebe132641edab54e1d89d163cc2534521a9f41750ed5bd8582723a2d28", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "26c8955179127e90c5b2750191b7ffa99d5d6a0c43b82a93fd3731334426e8e1", + "entry_digest": "0b185dd498f42e05a6cc8c2447d547432f4c40000c5dc26f17fe0fc7ae21d6cb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b673a3e3ad2964ca4fc0f8051d991edb321e42c3ffdc4a44c30d30449ab3eeb5", + "entry_digest": "8f011e80ca45a367403be199dd16fd7216064d746506a08e8de739fbeaf162da", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "5947d02b95d6205d63beca54876398d81226d0d4796b4234b646a3b919f08b4c", + "entry_digest": "a847aedf94fa22e35460140f14bdd4be567c550ddc2d20ade0f672329f3efbd7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "68f03f7ec7a10f052e147d57faf5e359e67b7582e06586078b72de53972071b8", + "entry_digest": "e6d8d515e176c6da445f7a402edbabea6e28bacaf74695db6154dcb7c56bced0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoKey.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae5452ba9738ccd90f004a65fcc64f9beeb4366dc8ab6a094be70db2356bc68b", + "entry_digest": "bab68a6071d00c323405116a9ff4380356c8477755850bbc124233ef6c667141", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoState.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "741c2c25af9d89a71e3831cbcec0e7b63efb982caa4c83ae7c7ab31dfbf93924", + "entry_digest": "883c29aefe922beb4e56beb2d3bae3d7ffd5047c05ee3453bbac9dcdfa6483a3", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "635340259dcd618bf5099e07dad823f72f4517f6a2762ec1e6b67965221ccef2", + "entry_digest": "637ab3fca33111ebb5f145cdd632e7a6412e99d88d08e3890ab1cc3edc54c736", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "1eec78c3da61ce03c9d9e0dcfa73b5a946fd9486446566f44f52ff568b66b742", + "entry_digest": "8632b57bb371b7e22f6e0991082590e89e53c29a2a08e5cb28ddb1e6cbca1375", + "owners": [ + "state-store" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "fc62708d2a2672bbcd27c3a75eb9a41005ae2d6d07cc657ba692e1839d114499", + "entry_digest": "529fbe2c459f55c31654c89244ea030feda05557fab79fda215fe41ed4755573", + "owners": [ + "@exact:test" + ] + }, + { + "path": "fledge.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "f69feb965265eb9481197160885409a03d0eb367149a2955c7b17a98dee9ba4e", + "entry_digest": "4994cce91eb6ed15dcf039ff4ce7194b901fe8e5ef33905503817173f42b6560", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "fc2f157fdcb2bc927a7ad41187993690389b2c9a71f4fba6077bcace0b84c1d2", + "entry_digest": "b025cb47565ae0dad36c2adf59145aa898d88782df84e8805489ac76abcbb787", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6a67354ec782d88510bea4b0ec28e1b1a3c50fc01e625ee32d6783f4dfbc86e0", + "entry_digest": "9cb95bb0d687799ba10637230b404cbef8a5abfb45d46a07a7bb50bee9703431", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "99f67a96136a4f15785230b61b5c74d57ccac1ad05877513097ff0280de510da", + "entry_digest": "c0c5fe71d6b614fe5e90a5c3ebba3b173a7253255b90c9748144d96ff366915e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6f0c8e3aa90f43e5dc40389e77088b2ec6ebb725c89c57217970fc1c221f527", + "entry_digest": "af31087c96d633274df81aecd4e99f520a55995882545190d2d42bb30fa5c3aa", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "14420af80f34d7954894aaf63f9864db13eea74549796043f54badf82805394a", + "entry_digest": "1227fad23fad49d312453dfe8fffc2bfe67396fdb29c88dab355bd60e8ec91b5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ddb4564d168db43eadd6315a7f56b532198905a79abebfee77db890db0f9c402", + "entry_digest": "a14edf94507dfd13d7d325bf68e370e0c1646e323b28341bd0c1dfff72f05d88", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "dfa974ed7b0c6488ba70d0f6cac9f574c4df29674612abeef1ec12dd89f8241a", + "entry_digest": "75cc7a8c3022b64210eab987dd93f655fb812deb45b7f79d064456e4539b6157", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "22e65ea3a60cd05e76d0450c8136609bd06257eb70a5ac624b8ba25ff4337cba", + "entry_digest": "010f681da95cc42be632e117cc6b41f2fa4abd33a62a30400a1c583479a65369", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "711db2a24a4959531ca966ac43d96e7817788ebe74077280a6cff950cb9a8779", + "entry_digest": "8402318068cd8ab6f8a596519289e70425dbc28bf6b31d5c1ef6f0a17f927f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "96fbc44cdbe25dfab52a47f8efdbd4698203571981b181c78a04769112402f1d", + "entry_digest": "8b0abd2a0f6613b9caa1f2831cdf8776b80a1945552eade241ff3d3b8d6404af", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + "stale_acceptance_input_digest": "b4503835c81ef2983e6deee01231adb7c39f133c1c4b3fbd7d149a09ac6f7c60", + "current_acceptance_input_digest": "b0acb6b35cd8a84250c1fa20cca529be2154e3eb5e1a555531ee1624d327572e" + } + ] +} diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md new file mode 100644 index 0000000..7aa85f2 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md @@ -0,0 +1,25 @@ +--- +id: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +state: accepted +type: feature +base_commit: c6d9326fd7f4c7925deb5ff4f27f984a073bf6b8 +--- + +# Fix FileState watch cache and path isolation from review + +## Intent + +Fix FileState watch cache and path isolation from review + +## Affected Canonical Specs + +- `aps-cli` +- `state-store` + +## Acceptance Criteria + +- watch note sees cross-process file writes; StateStore preserves injected FileState paths; set note fails when disk write does not persist; specs/README match; 20 tests pass + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md new file mode 100644 index 0000000..0cd21ca --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md @@ -0,0 +1,16 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: context +--- + +# Context + +PR review found that `watch note` could not see cross-process writes because +AppState FileState caches on first read. Also `StateStore.init` overwrote +test-injected FileState paths via `APSPaths.configure()`. + +## Decisions + +- Poll `note` by reading `note.json` directly (bypass AppState cache). +- Move `APSPaths.configure()` to CLI `boot()` only. +- After `set note`, verify on-disk value and throw `persistenceFailed` if mismatched. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md new file mode 100644 index 0000000..c266613 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md @@ -0,0 +1,43 @@ +# APS CLI FileState watch and error surface + +## MODIFIED + +### REQUIREMENT REQ-aps-cli-004 + +`watch` SHALL print the current value first and flush subsequent distinct values promptly, including cross-process `FileState` writes to `note`. + +Acceptance Criteria +- The first emitted line is the current value. +- Non-TTY stdout still surfaces each change without waiting for process exit. +- An external write to `note.json` is observed within one poll interval without relying on AppState's FileState cache. + +### REQUIREMENT REQ-aps-cli-005 + +`APSError` SHALL cover `unknownKey`, `invalidValue`, `encodingFailed`, `decodingFailed`, and `persistenceFailed`. + +Acceptance Criteria +- Each case has an actionable `description`. +- `set note` surfaces `persistenceFailed` when the on-disk value does not match after write. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum (`CaseIterable`, `ExpressibleByArgument`, `Sendable`). | +| `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`. | +| `unknownKey` | Unknown demo key token. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk-backed key did not persist after write. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState`). | +| `valueType` | Human value type (`Int` / `String` / `Bool`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | +| `description` | Actionable error text for humans and ValidationError bridging. | + +Command tree (informational): `Aps` is the `@main` root (`ParsableCommand`) with get, set, watch, dump, keys, and reset / reset --all. CLI `boot()` calls `APSPaths.configure()`. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md new file mode 100644 index 0000000..3f31bff --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md @@ -0,0 +1,53 @@ +# State Store FileState watch and path isolation + +## MODIFIED + +### REQUIREMENT REQ-state-store-001 + +`StateStore` SHALL read and write demo keys through AppState Application extensions on the main actor via `init`, `get`, and `set`, without overwriting an injected `FileManager.defaultFileStatePath`. + +Acceptance Criteria +- `get`/`set` round-trip `counter`, `message`, `flag`, and `note`. +- Mutating paths are MainActor-isolated. +- `init` loads dependencies only; CLI `boot()` (or tests) configure FileState paths. + +### REQUIREMENT REQ-state-store-003 + +Writing `flag` SHALL flush UserDefaults so Linux short-lived processes persist StoredState; writing `note` SHALL verify the on-disk value and throw `APSError.persistenceFailed` when persistence fails; `reset` / `resetAll` restore initials. + +Acceptance Criteria +- After `set(.flag, "true")`, a new `StateStore` instance observes true. +- After a successful `set(.note, ...)`, `readNoteFromDisk()` returns the same value. +- `reset(.flag)` restores false and flushes. + +### REQUIREMENT REQ-state-store-004 + +`watchBlocking` SHALL combine Observation with RunLoop polling and honor `shouldContinue`; for `note`, polling SHALL read the file directly so cross-process writes are visible despite AppState FileState caching; `parseBool` accepts common truthy/falsey tokens. + +Acceptance Criteria +- In-process `State` mutations are observed. +- External writes to `note.json` are observed without updating AppState's cache. +- `shouldContinue` false stops the loop without requiring Ctrl-C. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `StateStore` | MainActor AppState facade used by the CLI. | +| `APSClock` | Clock protocol for dump timestamps. | +| `SystemAPSClock` | Production `APSClock` backed by `Date()`. | +| `JSONCoding` | Shared pretty JSON helpers. | +| `init` | Loads clock/jsonCoding dependencies without forcing `~/.aps`. | +| `get` | Return the string form of a demo key. | +| `set` | Parse and write; throw `APSError.invalidValue` or `persistenceFailed` on failure. | +| `reset` | Restore one key to its AppState initial value. | +| `resetAll` | Restore every demo key. | +| `dump` | Pretty JSON snapshot using `@AppDependency` clock + jsonCoding. | +| `watchBlocking` | Observation + RunLoop poll loop; `note` polls via direct disk read. | +| `parseBool` | Accept true/false/1/0/yes/no/on/off (case-insensitive). | +| `now` | Current `Date` from an `APSClock`. | +| `encodePretty` | Encode an `Encodable` value as pretty UTF-8 JSON text. | +| `decode` | Decode a `Decodable` value from UTF-8 JSON text. | +| `readNoteFromDisk` | Read `note.json` without touching AppState's FileState cache. | + +Application demo surface (informational): `Application.counter` / `message` / `flag` / `note` / `clock` / `jsonCoding`. `APSPaths.configure()` is invoked from CLI `boot()`, not `StateStore.init`. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md new file mode 100644 index 0000000..43f0e71 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md @@ -0,0 +1,12 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: design +--- + +# Design + +Watch polling for `note` uses a direct JSON read of `note.json` under +`FileManager.defaultFileStatePath`, matching AppState's non-Base64 FileState +encoding. In-process `get`/`set` still go through AppState FileState so the +CLI continues to dogfood the library. Path configuration is a CLI boot concern +so tests can inject temp directories. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md new file mode 100644 index 0000000..a81fbdb --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md @@ -0,0 +1,9 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: docs +--- + +# Docs + +README clarifies that `watch note` polls the file directly for cross-process +updates, and softens the untested Linux CI claim. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md new file mode 100644 index 0000000..c2a299c --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md @@ -0,0 +1,12 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: plan +--- + +# Plan + +1. Add `freshValue` / `readNoteFromDisk` for watch polling of `note`. +2. Stop configuring FileState paths inside `StateStore.init`; call from `boot()`. +3. Verify note persistence after set; add `APSError.persistenceFailed`. +4. Add tests for external file writes and injected path isolation. +5. Update specs/README to match. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md new file mode 100644 index 0000000..3b158a0 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md @@ -0,0 +1,9 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: requirements +--- + +# Requirements + +See semantic deltas for `REQ-aps-cli-004`, `REQ-aps-cli-005`, +`REQ-state-store-001`, `REQ-state-store-003`, and `REQ-state-store-004`. diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json new file mode 100644 index 0000000..5c6f315 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "slug": "fix-filestate-watch-cache-and-path-isolation-from-review", + "title": "Fix FileState watch cache and path isolation from review", + "description": "Fix FileState watch cache and path isolation from review", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "c6d9326fd7f4c7925deb5ff4f27f984a073bf6b8", + "created_at": 1784396041, + "updated_at": 1784397245, + "affected_specs": [ + "aps-cli", + "state-store" + ], + "affected_paths": [ + ".attest.json", + ".augur.toml", + ".github/workflows/", + ".gitignore", + ".specsync/", + ".trust.toml", + "AGENTS.md", + "CLAUDE.md", + "Package.resolved", + "Package.swift", + "README.md", + "Scripts/", + "Sources/", + "Tests/", + "fledge.toml", + "specs/" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "watch note sees cross-process file writes; StateStore preserves injected FileState paths; set note fails when disk write does not persist; specs/README match; 20 tests pass" + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [ + "CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts" + ], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md new file mode 100644 index 0000000..8ffbef4 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md @@ -0,0 +1,12 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: tasks +--- + +# Tasks + +- [x] Bypass FileState cache while polling `note` +- [x] Move `APSPaths.configure()` to CLI boot +- [x] Surface note persistence failures +- [x] Tests for external write watch + injected path +- [x] Spec/README updates diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md new file mode 100644 index 0000000..0a6cd55 --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md @@ -0,0 +1,22 @@ +--- +change: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +|-------------|----------| +| REQ-aps-cli-004 | `testWatchDetectsExternalFileStateWrite`, `testWatchDetectsFileStateChange` | +| REQ-aps-cli-005 | `testAPSErrorDescriptionsAreActionable` | +| REQ-state-store-001 | `testNoteUsesInjectedFileStatePath`, round-trip tests | +| REQ-state-store-003 | `testNoteFileStateRoundTrip`, `testFlagPersistsAcrossStateStoreInstances` | +| REQ-state-store-004 | `testWatchDetectsExternalFileStateWrite`, `testParseBool` | + +## Gate evidence + +- `swift test` (20 tests) +- `./Scripts/smoke.sh` +- `fledge lanes run verify` diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json new file mode 100644 index 0000000..2f5337b --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json @@ -0,0 +1,89 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784397102, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "80cda71fb38070ef0014a9230a3c5f96ed00a7b41e6e8a88cd722533f0097d24", + "workspace_digest": "00103adb8e10e5ba2e183296a6112b25b23b3554206ea7c9a63ed916cb55fbda", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + { + "timestamp": 1784397145, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "00103adb8e10e5ba2e183296a6112b25b23b3554206ea7c9a63ed916cb55fbda", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + { + "timestamp": 1784397207, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "cfcca5e750e6b8e46ae8df74f0d7ffa9133287c5a38c8437f74e8c129303a676", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + }, + { + "timestamp": 1784397244, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "91ba3c897a1d5599c3bcd61040b00e73767d6698b1266510ff07e2b7b6a9ddad", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] + } + ] +} diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json new file mode 100644 index 0000000..a57920f --- /dev/null +++ b/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json @@ -0,0 +1,377 @@ +{ + "timestamp": 1784397244, + "commit": "8a76d01878593200ff1f1ba2104fea12a30db083", + "contract_digest": "6d4175cb4100733aee98f1debbdd8aa5736bfc235a4e21bc742b76b66195c817", + "workspace_digest": "91ba3c897a1d5599c3bcd61040b00e73767d6698b1266510ff07e2b7b6a9ddad", + "acceptance_input_digest": "b0acb6b35cd8a84250c1fa20cca529be2154e3eb5e1a555531ee1624d327572e", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".attest.json", + "kind": "file", + "mode": 33188, + "payload_digest": "fda17571040ce10c378e009859f92cc33afd67b5c62d3f96510bf9e6e5de0a9f", + "entry_digest": "9d185be1f08f917f1162191bbd90ca325f047bec1cda66f2fc6d0737c08da1ab", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".augur.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "17f6ad5b84b4db012f0e3f398a96a478c1324fd47e72e1dd2a13c7cf9a432983", + "entry_digest": "bac13c952da05292bdd0af5849aad9d9def3ae5afc830fa42b6914b2fd874beb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/ci.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1f310d5d11b4466aedf85aac75d7db13b07d564f1e38e2f596defb8cf2f06d1c", + "entry_digest": "9abaefb404d6bb884367be7ea7524e890c9bab326c810c0985569c35a83418e7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/trust.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "e165929994150d856d05ad048d8e1032e09df989c78ea3f1390d4e885b50af56", + "entry_digest": "e405b6225fdb502cfae9b22a9c14e21be8d1715bbaf8d56cdfd178bb8e173cb9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "30d23b531c3fcb3e46c413c3d06d3c2848bb273aaf86abdee660e201ed9519a9", + "entry_digest": "8c2aa3eee1fa32d85bddb1f5daf626a0a97ce920f434cd2ec57050fe1623867a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "3f711fd3f671890cacd293a97ff33223806fce0ce831a54dbd4aad1eeba79433", + "entry_digest": "ae757f4ebb20a9690f0644cc138231711296318c7beafb7108d256d35cca3797", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".trust.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "6284ced27613c8e2dd8266ffd1668f021cb24e1edb6ccd79c9ade86032c09ce8", + "entry_digest": "7cdd9144620ced58d9ac48ee7d2b8563f5564f1ae2db02a22e7eed781963257e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "AGENTS.md", + "kind": "file", + "mode": 33188, + "payload_digest": "a671dd50eea50f115e6f9abd554e5a8ad7e520208fec5c4bd540c4f3a07ef807", + "entry_digest": "c321960d8ebbf18c2833c8f8b7fe413d6f0e349b424bf2b296a4da7a26316750", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "CLAUDE.md", + "kind": "file", + "mode": 33188, + "payload_digest": "e2df5028858a540821e4a666297d536a14bf598d48afe6c44df921b0f1ddbf32", + "entry_digest": "6930540d6a42ffd8c849bdf10eaf69c3f231cf5e824c9e6f0e694d3edb91bda3", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.resolved", + "kind": "file", + "mode": 33188, + "payload_digest": "12eea9fa129e151eaffdc230a234293407f0e3a99c52f15597c72e4e591c0e1e", + "entry_digest": "554d48ebe132641edab54e1d89d163cc2534521a9f41750ed5bd8582723a2d28", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Package.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "26c8955179127e90c5b2750191b7ffa99d5d6a0c43b82a93fd3731334426e8e1", + "entry_digest": "0b185dd498f42e05a6cc8c2447d547432f4c40000c5dc26f17fe0fc7ae21d6cb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "658cdb35f5117c19dd9688ce79fba502029d0ca34789482a333017d155ad99d8", + "entry_digest": "3e8da8bd1905678d7101a98942b04342ef85182909091d2307fd5018438fefa9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "5947d02b95d6205d63beca54876398d81226d0d4796b4234b646a3b919f08b4c", + "entry_digest": "a847aedf94fa22e35460140f14bdd4be567c550ddc2d20ade0f672329f3efbd7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "68f03f7ec7a10f052e147d57faf5e359e67b7582e06586078b72de53972071b8", + "entry_digest": "e6d8d515e176c6da445f7a402edbabea6e28bacaf74695db6154dcb7c56bced0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoKey.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae5452ba9738ccd90f004a65fcc64f9beeb4366dc8ab6a094be70db2356bc68b", + "entry_digest": "bab68a6071d00c323405116a9ff4380356c8477755850bbc124233ef6c667141", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoState.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "741c2c25af9d89a71e3831cbcec0e7b63efb982caa4c83ae7c7ab31dfbf93924", + "entry_digest": "883c29aefe922beb4e56beb2d3bae3d7ffd5047c05ee3453bbac9dcdfa6483a3", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "635340259dcd618bf5099e07dad823f72f4517f6a2762ec1e6b67965221ccef2", + "entry_digest": "637ab3fca33111ebb5f145cdd632e7a6412e99d88d08e3890ab1cc3edc54c736", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "1eec78c3da61ce03c9d9e0dcfa73b5a946fd9486446566f44f52ff568b66b742", + "entry_digest": "8632b57bb371b7e22f6e0991082590e89e53c29a2a08e5cb28ddb1e6cbca1375", + "owners": [ + "state-store" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "fc62708d2a2672bbcd27c3a75eb9a41005ae2d6d07cc657ba692e1839d114499", + "entry_digest": "529fbe2c459f55c31654c89244ea030feda05557fab79fda215fe41ed4755573", + "owners": [ + "@exact:test" + ] + }, + { + "path": "fledge.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "f69feb965265eb9481197160885409a03d0eb367149a2955c7b17a98dee9ba4e", + "entry_digest": "4994cce91eb6ed15dcf039ff4ce7194b901fe8e5ef33905503817173f42b6560", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "fc2f157fdcb2bc927a7ad41187993690389b2c9a71f4fba6077bcace0b84c1d2", + "entry_digest": "b025cb47565ae0dad36c2adf59145aa898d88782df84e8805489ac76abcbb787", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6a67354ec782d88510bea4b0ec28e1b1a3c50fc01e625ee32d6783f4dfbc86e0", + "entry_digest": "9cb95bb0d687799ba10637230b404cbef8a5abfb45d46a07a7bb50bee9703431", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "99f67a96136a4f15785230b61b5c74d57ccac1ad05877513097ff0280de510da", + "entry_digest": "c0c5fe71d6b614fe5e90a5c3ebba3b173a7253255b90c9748144d96ff366915e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6f0c8e3aa90f43e5dc40389e77088b2ec6ebb725c89c57217970fc1c221f527", + "entry_digest": "af31087c96d633274df81aecd4e99f520a55995882545190d2d42bb30fa5c3aa", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "14420af80f34d7954894aaf63f9864db13eea74549796043f54badf82805394a", + "entry_digest": "1227fad23fad49d312453dfe8fffc2bfe67396fdb29c88dab355bd60e8ec91b5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ddb4564d168db43eadd6315a7f56b532198905a79abebfee77db890db0f9c402", + "entry_digest": "a14edf94507dfd13d7d325bf68e370e0c1646e323b28341bd0c1dfff72f05d88", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "dfa974ed7b0c6488ba70d0f6cac9f574c4df29674612abeef1ec12dd89f8241a", + "entry_digest": "75cc7a8c3022b64210eab987dd93f655fb812deb45b7f79d064456e4539b6157", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "22e65ea3a60cd05e76d0450c8136609bd06257eb70a5ac624b8ba25ff4337cba", + "entry_digest": "010f681da95cc42be632e117cc6b41f2fa4abd33a62a30400a1c583479a65369", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "711db2a24a4959531ca966ac43d96e7817788ebe74077280a6cff950cb9a8779", + "entry_digest": "8402318068cd8ab6f8a596519289e70425dbc28bf6b31d5c1ef6f0a17f927f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "96fbc44cdbe25dfab52a47f8efdbd4698203571981b181c78a04769112402f1d", + "entry_digest": "8b0abd2a0f6613b9caa1f2831cdf8776b80a1945552eade241ff3d3b8d6404af", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-004", + "REQ-aps-cli-005", + "REQ-state-store-001", + "REQ-state-store-003", + "REQ-state-store-004" + ] +} diff --git a/.specsync/config.toml b/.specsync/config.toml new file mode 100644 index 0000000..b4b24fa --- /dev/null +++ b/.specsync/config.toml @@ -0,0 +1,11 @@ +# spec-sync v5 configuration +# Docs: https://github.com/CorvidLabs/spec-sync +specs_dir = "specs" +source_dirs = ["Sources/aps", "Tests/apsTests"] +exclude_dirs = [] +exclude_patterns = [] +required_sections = ["Purpose", "Public API", "Invariants", "Behavioral Examples", "Error Cases", "Dependencies", "Change Log"] +enforcement = "strict" + +[lifecycle] +track_history = false diff --git a/.specsync/registry.toml b/.specsync/registry.toml new file mode 100644 index 0000000..190f57d --- /dev/null +++ b/.specsync/registry.toml @@ -0,0 +1,6 @@ +[registry] +name = "aps" + +[specs] +aps-cli = "specs/aps-cli/aps-cli.spec.md" +state-store = "specs/state-store/state-store.spec.md" diff --git a/.specsync/sdd.json b/.specsync/sdd.json new file mode 100644 index 0000000..7a44ca9 --- /dev/null +++ b/.specsync/sdd.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "enabled": true, + "require_change_for_meaningful_files": true, + "meaningful_paths": [ + ".", + "Sources/", + "Tests/", + "specs/", + ".github/", + "Package.swift", + "Package.resolved", + ".specsync/sdd.json", + ".specsync/config.toml", + ".specsync/registry.toml", + ".specsync/version", + "fledge.toml", + ".trust.toml", + ".augur.toml", + ".attest.json", + "AGENTS.md", + "CLAUDE.md", + "Scripts/" + ], + "ignored_paths": [], + "verification_commands": [ + "fledge lanes run verify" + ], + "custom_artifacts": {}, + "principles_file": null +} diff --git a/.specsync/version b/.specsync/version new file mode 100644 index 0000000..ac14c3d --- /dev/null +++ b/.specsync/version @@ -0,0 +1 @@ +5.1.1 diff --git a/.trust.toml b/.trust.toml new file mode 100644 index 0000000..4c56b60 --- /dev/null +++ b/.trust.toml @@ -0,0 +1,22 @@ +schema_version = 1 +profile = "standard" + +[lifecycle] +command = ["fledge", "lanes", "run", "verify"] + +[contract] +enabled = true +require_coverage = 0 +skip_reason = "" + +[risk] +threshold = "block" + +[provenance] +mode = "soft" +policy = ".attest.json" +skip_reason = "" + +[atlas] +enabled = false +skip_reason = "Atlas publication is deferred until GitHub Pages is enabled for this private repo" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9bfd9df --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# aps + +A Swift CLI that dogfoods [AppState](https://github.com/0xLeif/AppState) outside SwiftUI. + +## Layout + +| Path | Role | +|------|------| +| `Sources/aps/` | Executable: CLI, demo Application state, StateStore | +| `Tests/apsTests/` | Round-trip, watch, reset, and dependency tests | +| `specs/` | SpecSync contracts for the CLI and state surface | +| `Scripts/smoke.sh` | End-to-end CLI smoke checks | + +## Workflow + +```sh +fledge lanes run verify # build + test + smoke +fledge trust verify # full trust gate when tools are installed +./Scripts/smoke.sh +``` + + +## CorvidLabs trust toolchain (standing rules) + +This repo is gated by four tools, run by `.github/workflows/trust.yml`: + +1. **fledge**: the quality gate. `fledge lanes run verify` runs + build + test + smoke. Prefer fledge wrappers over raw tools. +2. **spec-sync**: specs are contracts. Each module API has a `*.spec.md`, and + `specsync check` must pass. Skipping spec-sync for a repo needs an explicit + one-line reason. +3. **augur**: deterministic diff-risk scoring. A `block` verdict halts the + merge. `augur.json` is a per-run artifact and is gitignored; never commit it. +4. **attest**: signed provenance. CI records an attestation and verifies the + range against `.attest.json`. Provenance lives in `refs/notes/attest`. + +Standing rules for anyone (human or agent) changing this repo: + +- Run `fledge lanes run verify` before pushing; do not bypass the gate. +- Keep specs in lockstep with code: update the `*.spec.md` in the same change. +- A `block` verdict from augur means stop and escalate, not merge. +- Do not commit `augur.json`. +- Do not use em-dash characters in authored content; use hyphens or colons. +- Runner-specific rule files (`CLAUDE.md`, `.cursor/rules/*.mdc`, + `.github/copilot-instructions.md`) are one-line pointers to this file; do not + duplicate these rules into them. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4166c46 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See AGENTS.md for the project rules, including the managed CorvidLabs trust toolchain block. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..810ca4c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "26c8955179127e90c5b2750191b7ffa99d5d6a0c43b82a93fd3731334426e8e1", + "pins" : [ + { + "identity" : "appstate", + "kind" : "remoteSourceControl", + "location" : "https://github.com/0xLeif/AppState", + "state" : { + "revision" : "5053feab3497605386613e4309bc0cf8c95dc5c4", + "version" : "3.0.1" + } + }, + { + "identity" : "cache", + "kind" : "remoteSourceControl", + "location" : "https://github.com/0xLeif/Cache", + "state" : { + "revision" : "a959a473d81da9aeb17dac7c050f0a07775f2c18", + "version" : "2.1.3" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c8a46e8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "aps", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable( + name: "aps", + targets: ["aps"] + ) + ], + dependencies: [ + .package(url: "https://github.com/0xLeif/AppState", from: "3.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0") + ], + targets: [ + .executableTarget( + name: "aps", + dependencies: [ + .product(name: "AppState", package: "AppState"), + .product(name: "ArgumentParser", package: "swift-argument-parser") + ] + ), + .testTarget( + name: "apsTests", + dependencies: ["aps"] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md index 897f8bd..ae5f9be 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,139 @@ -# aps-cli -📀 AppState CLI +# aps + +A tiny Swift CLI that [dogfoods](https://github.com/0xLeif/AppState) **AppState** outside SwiftUI: declare typed app state, get/set/watch/dump it, and show dependency injection. + +Targets **macOS** (CI) and aims to stay Linux-friendly where AppState allows. + +This repository is gated by the [CorvidLabs trust toolchain](https://corvidlabs.xyz/integrate/) (fledge, spec-sync, augur, attest). See `AGENTS.md`. + +## Commands + +```text +aps get +aps set +aps watch # print on change (Observation + polling) +aps dump # print all known state as JSON +aps keys # list demo keys / storage kinds +aps reset # restore one key to its initial value +aps reset --all +aps --help +``` + +### Demo keys (fixed schema) + +| Key | Type | Storage | Lifetime | +| --- | --- | --- | --- | +| `counter` | `Int` | `State` | Process (in-memory) | +| `message` | `String` | `State` | Process (in-memory) | +| `flag` | `Bool` | `StoredState` | Persisted (`UserDefaults`; CLI calls `synchronize()` so Linux flushes) | +| `note` | `String` | `FileState` | Persisted (`~/.aps/note.json`) | + +Dynamic / user-declared keys are intentionally out of scope for v1. + +### Dependencies + +`aps` injects real services with `@AppDependency` / `Application.dependency`: + +- **`clock`** : wall clock for dump timestamps +- **`jsonCoding`** : shared JSON encoder helpers for `aps dump` + +## Requirements + +- Swift 6.0+ +- macOS 14+ (CI). Linux toolchains are supported best-effort, not gated in CI yet. +- For the trust gate locally: [corvid-trust](https://github.com/CorvidLabs/trust) (`brew install CorvidLabs/tap/corvid-trust`) +- SpecSync **5.1.1** (see `.specsync/version`). Trust CI mirrors that exact release; brew `spec-sync` latest should match. + +## Build and run + +```bash +git clone https://github.com/0xLeif/aps-cli.git +cd aps-cli +swift build +swift run aps --help +``` + +Or through fledge: + +```bash +fledge lanes run verify +``` + +Release build: + +```bash +swift build -c release +.build/release/aps dump +``` + +### Examples + +```bash +swift run aps keys +swift run aps set counter 3 +swift run aps set flag true +swift run aps set note "saved across launches" +swift run aps dump +swift run aps watch note --interval 200 +swift run aps reset --all +``` + +`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`, polling reads `note.json` directly so AppState's FileState cache cannot hide cross-process writes. + +## Tests and smoke + +```bash +swift test +./Scripts/smoke.sh +``` + +## CI (private repo) + +While this repository is **private**, every workflow runs on **macOS self-hosted** runners: + +| Workflow | Runner | Role | +|----------|--------|------| +| `.github/workflows/ci.yml` | `[self-hosted, macOS]` | build / test / smoke | +| `.github/workflows/trust.yml` | `[self-hosted, macOS]` | CorvidLabs Trust gate (fledge + spec-sync + augur + attest) | + +Before making the repo public, switch off self-hosted runners for fork pull requests. + +## Trust toolchain + +| File | Purpose | +|------|---------| +| `fledge.toml` | Tasks + `verify` lane | +| `.trust.toml` | Unified Trust policy | +| `.augur.toml` | Diff-risk thresholds | +| `.attest.json` | Provenance policy | +| `.specsync/` | SpecSync 5.1.1 config + SDD change tracking (`.specsync/version`) | +| `specs/` | Module contracts (`aps-cli`, `state-store`) | +| `AGENTS.md` | Standing rules (managed block required by CI) | + +```bash +fledge trust doctor +fledge trust verify +``` + +## Layout + +```text +Package.swift +Sources/aps/ +Tests/apsTests/ +specs/ +Scripts/smoke.sh +.github/workflows/{ci,trust}.yml +``` + +## Non-goals (v1) + +- No iCloud `SyncState`, Keychain `SecureState`, or SwiftData `ModelState` +- No plugin system, daemon, or network API +- No dynamic schema language: fixed demo keys only + +## Related + +- [AppState](https://github.com/0xLeif/AppState) +- [CorvidLabs Trust](https://github.com/CorvidLabs/trust) +- [Integrate guide](https://corvidlabs.xyz/integrate/) diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh new file mode 100755 index 0000000..d0c3f75 --- /dev/null +++ b/Scripts/smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +if [[ -z "${APS_BIN:-}" ]]; then + swift build -c debug + APS_BIN=".build/debug/aps" +fi +bin="$APS_BIN" + +"$bin" --help >/dev/null +"$bin" keys | grep -q counter + +# `set` prints the value; State is process-local so don't expect get in a new process. +test "$("$bin" set counter 11)" = "11" +test "$("$bin" set message "smoke")" = "smoke" + +# StoredState / FileState must survive process boundaries. +"$bin" set flag true >/dev/null +test "$("$bin" get flag)" = "true" + +"$bin" set note "smoke-note" >/dev/null +test "$("$bin" get note)" = "smoke-note" + +"$bin" dump | grep -q '"key" : "flag"' + +"$bin" reset flag >/dev/null +test "$("$bin" get flag)" = "false" + +"$bin" reset note >/dev/null +test -z "$("$bin" get note)" + +"$bin" reset --all >/dev/null +test "$("$bin" get flag)" = "false" +test -z "$("$bin" get note)" + +# Invalid values should fail clearly. +if "$bin" set counter nope >/dev/null 2>&1; then + echo "expected invalid counter to fail" >&2 + exit 1 +fi + +echo "smoke ok" diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift new file mode 100644 index 0000000..65f71d9 --- /dev/null +++ b/Sources/aps/Aps.swift @@ -0,0 +1,176 @@ +import ArgumentParser +import AppState +import Foundation + +@main +struct Aps: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "aps", + abstract: "A tiny CLI that dogfoods AppState outside SwiftUI.", + discussion: """ + Demo keys (fixed schema for v1): + counter Int State (in-memory) + message String State (in-memory) + flag Bool StoredState (UserDefaults) + note String FileState (~/.aps/note.json) + + Built on https://github.com/0xLeif/AppState + """, + version: "0.1.0", + subcommands: [ + Get.self, + Set.self, + Watch.self, + Dump.self, + Keys.self, + Reset.self + ], + defaultSubcommand: nil + ) +} + +extension Aps { + struct Get: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print the current value for a demo key." + ) + + @Argument(help: "Demo key: counter | message | flag | note") + var key: DemoKey + + func run() throws { + try onMainThread { + boot() + print(StateStore().get(key)) + } + } + } + + struct Set: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Set a demo key to a value." + ) + + @Argument(help: "Demo key: counter | message | flag | note") + var key: DemoKey + + @Argument(help: "New value (Bool: true/false/1/0; Int for counter)") + var value: String + + func run() throws { + try onMainThread { + boot() + let store = StateStore() + do { + try store.set(key, value: value) + } catch let error as APSError { + throw ValidationError(error.description) + } + print(store.get(key)) + } + } + } + + struct Watch: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print the value whenever it changes (Observation + polling)." + ) + + @Argument(help: "Demo key: counter | message | flag | note") + var key: DemoKey + + @Option(name: .long, help: "Poll interval in milliseconds (fallback for disk-backed keys).") + var interval: UInt64 = 250 + + func run() throws { + try onMainThread { + boot() + let store = StateStore() + store.watchBlocking(key, pollInterval: TimeInterval(interval) / 1000.0) { value in + // Write via FileHandle so output appears immediately when stdout is not a TTY. + if let data = (value + "\n").data(using: .utf8) { + FileHandle.standardOutput.write(data) + } + } + } + } + } + + struct Dump: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print all known demo keys as pretty JSON." + ) + + func run() throws { + try onMainThread { + boot() + print(try StateStore().dump()) + } + } + } + + struct Keys: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List the fixed demo keys and how they are stored." + ) + + func run() throws { + print("KEY\tTYPE\tSTORAGE\tDESCRIPTION") + for key in DemoKey.allCases { + print("\(key.helpSummary)\t\(key.detail)") + } + } + } + + struct Reset: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Reset one demo key (or all keys) back to its initial value." + ) + + @Argument(help: "Demo key to reset. Omit with --all.") + var key: DemoKey? + + @Flag(name: .long, help: "Reset every demo key.") + var all: Bool = false + + func run() throws { + guard all || key != nil else { + throw ValidationError("Pass a key or --all. Example: aps reset counter") + } + if all && key != nil { + throw ValidationError("Pass either a key or --all, not both.") + } + + try onMainThread { + boot() + let store = StateStore() + if all { + store.resetAll() + print("reset all keys") + } else if let key { + store.reset(key) + print(store.get(key)) + } + } + } + } +} + +@MainActor +private func boot() { + Application.logging(isEnabled: false) + APSPaths.configure() +} + +/// Synchronous `@main` starts on the real main thread; treat that as MainActor for AppState. +private func onMainThread( + _ body: @MainActor () throws -> T +) throws -> T { + precondition( + Thread.isMainThread, + "aps must run on the main thread so AppState can notify observers" + ) + return try MainActor.assumeIsolated { + try body() + } +} diff --git a/Sources/aps/DemoKey.swift b/Sources/aps/DemoKey.swift new file mode 100644 index 0000000..4939d98 --- /dev/null +++ b/Sources/aps/DemoKey.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Foundation + +/// Fixed demo keys the CLI understands. +public enum DemoKey: String, CaseIterable, ExpressibleByArgument, Sendable { + case counter + case message + case flag + case note + + public var storage: String { + switch self { + case .counter, .message: return "State" + case .flag: return "StoredState" + case .note: return "FileState" + } + } + + public var valueType: String { + switch self { + case .counter: return "Int" + case .message, .note: return "String" + case .flag: return "Bool" + } + } + + public var helpSummary: String { + "\(rawValue)\t\(valueType)\t\(storage)" + } + + /// Human-readable one-liner for `aps keys`. + public var detail: String { + switch self { + case .counter: + return "in-memory Int counter (process lifetime)" + case .message: + return "in-memory String (process lifetime)" + case .flag: + return "Bool via StoredState / UserDefaults" + case .note: + return "String via FileState (~/.aps/note.json)" + } + } +} + +public enum APSError: Error, CustomStringConvertible, Equatable { + case unknownKey(String) + case invalidValue(key: DemoKey, value: String) + case encodingFailed + case decodingFailed + case persistenceFailed(key: DemoKey) + + public var description: String { + switch self { + case .unknownKey(let key): + return "Unknown key '\(key)'. Known keys: \(DemoKey.allCases.map(\.rawValue).joined(separator: ", "))" + case .invalidValue(let key, let value): + return "Invalid value '\(value)' for \(key.rawValue) (\(key.valueType))" + case .encodingFailed: + return "Failed to encode value as UTF-8 JSON" + case .decodingFailed: + return "Failed to decode value from UTF-8 JSON" + case .persistenceFailed(let key): + return "Failed to persist \(key.rawValue) to disk" + } + } +} diff --git a/Sources/aps/DemoState.swift b/Sources/aps/DemoState.swift new file mode 100644 index 0000000..87fc7ee --- /dev/null +++ b/Sources/aps/DemoState.swift @@ -0,0 +1,59 @@ +import AppState +import Foundation + +/// Demo keys registered on `Application`: a tiny fixed schema for the CLI. +/// +/// Future idea: dynamic / user-declared keys without rebuilding. +extension Application { + /// In-memory integer counter (process lifetime). + var counter: State { + state(initial: 0, id: "aps.counter") + } + + /// In-memory string message (process lifetime). + var message: State { + state(initial: "", id: "aps.message") + } + + /// Persisted boolean flag via `UserDefaults` (`StoredState`). + var flag: StoredState { + storedState(initial: false, id: "aps.flag") + } + + /// Persisted note on disk via `FileState`. + @MainActor + var note: FileState { + fileState( + initial: "", + filename: "note.json", + isBase64Encoded: false + ) + } + + /// Wall-clock used when stamping watch/dump output. + var clock: Dependency { + dependency(SystemAPSClock()) + } + + /// Shared JSON encoder for pretty CLI dumps. + var jsonCoding: Dependency { + dependency(JSONCoding()) + } +} + +/// Stable paths for CLI-persisted `FileState` data. +/// +/// Called from CLI `boot()` only. Tests inject their own +/// `FileManager.defaultFileStatePath` before constructing `StateStore`. +enum APSPaths { + @MainActor + static var fileStateDirectory: String { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".aps", isDirectory: true).path + } + + @MainActor + static func configure() { + FileManager.defaultFileStatePath = fileStateDirectory + } +} diff --git a/Sources/aps/Dependencies.swift b/Sources/aps/Dependencies.swift new file mode 100644 index 0000000..e85349b --- /dev/null +++ b/Sources/aps/Dependencies.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Clock abstraction injected through AppState. +public protocol APSClock: Sendable { + var now: Date { get } +} + +/// Production clock backed by `Date()`. +public struct SystemAPSClock: APSClock { + public init() {} + + public var now: Date { Date() } +} + +/// Real JSON helpers used by dump / formatting: not a stub. +public struct JSONCoding: Sendable { + public init() {} + + public func encodePretty(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } + + public func decode(_ type: T.Type, from string: String) throws -> T { + guard let data = string.data(using: .utf8) else { + throw APSError.decodingFailed + } + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Sources/aps/StateStore.swift b/Sources/aps/StateStore.swift new file mode 100644 index 0000000..a618361 --- /dev/null +++ b/Sources/aps/StateStore.swift @@ -0,0 +1,217 @@ +import AppState +import Foundation +import Observation + +/// Reads and writes demo keys through AppState idioms (including `@AppDependency`). +/// +/// Callers must be on the main thread: AppState asserts that in `notifyChange()`, +/// and ArgumentParser's synchronous `@main` entry point provides that. +/// +/// FileState path configuration belongs to CLI `boot()` (or the test harness). +/// `StateStore` does not call `APSPaths.configure()`, so injected test paths stay put. +@MainActor +public final class StateStore { + @AppDependency(\.clock) private var clock: any APSClock + @AppDependency(\.jsonCoding) private var jsonCoding: JSONCoding + + public init() { + Application.load(dependency: \.clock) + Application.load(dependency: \.jsonCoding) + } + + public func get(_ key: DemoKey) -> String { + switch key { + case .counter: + return String(Application.state(\.counter).value) + case .message: + return Application.state(\.message).value + case .flag: + return String(Application.state(\.flag).value) + case .note: + return Application.fileState(\.note).value + } + } + + public func set(_ key: DemoKey, value: String) throws { + switch key { + case .counter: + guard let intValue = Int(value) else { + throw APSError.invalidValue(key: key, value: value) + } + var state = Application.state(\.counter) + state.value = intValue + case .message: + var state = Application.state(\.message) + state.value = value + case .flag: + guard let boolValue = Self.parseBool(value) else { + throw APSError.invalidValue(key: key, value: value) + } + var state = Application.state(\.flag) + state.value = boolValue + // Linux Foundation does not always flush UserDefaults on process exit. + UserDefaults.standard.synchronize() + case .note: + var state = Application.fileState(\.note) + state.value = value + // AppState FileState swallows save errors after updating its cache. + // Confirm the value is actually on disk before claiming success. + let onDisk = try Self.readNoteFromDisk() + guard onDisk == value else { + throw APSError.persistenceFailed(key: .note) + } + } + } + + public func reset(_ key: DemoKey) { + switch key { + case .counter: + Application.reset(\.counter) + case .message: + Application.reset(\.message) + case .flag: + Application.reset(storedState: \.flag) + UserDefaults.standard.synchronize() + case .note: + Application.reset(fileState: \.note) + } + } + + public func resetAll() { + for key in DemoKey.allCases { + reset(key) + } + } + + public func dump() throws -> String { + let snapshot = DumpSnapshot( + timestamp: clock.now, + keys: DemoKey.allCases.map { key in + DumpEntry( + key: key.rawValue, + storage: key.storage, + type: key.valueType, + value: get(key) + ) + } + ) + return try jsonCoding.encodePretty(snapshot) + } + + /// Blocking watch for the synchronous CLI: Observation + RunLoop polling. + /// + /// - Observation covers in-process mutations (`State`). + /// - Polling re-reads values so `FileState` / `StoredState` updates can surface when + /// Observation alone would not (e.g. another process wrote the file). + /// - For `note`, polling reads the file directly so AppState's FileState cache cannot + /// hide cross-process writes. + /// - `shouldContinue` lets tests (and future tooling) stop the loop cleanly. + public func watchBlocking( + _ key: DemoKey, + pollInterval: TimeInterval = 0.25, + shouldContinue: () -> Bool = { true }, + onChange: (String) -> Void + ) { + var last = freshValue(key) + onChange(last) + + let slice = max(pollInterval / 5.0, 0.05) + + while shouldContinue() { + let flag = ChangeFlag() + + withObservationTracking { + self.readForObservation(key) + } onChange: { + flag.mark() + } + + while shouldContinue() { + RunLoop.current.run(until: Date(timeIntervalSinceNow: slice)) + let current = freshValue(key) + if flag.isSet || current != last { + if current != last { + last = current + onChange(current) + } + break + } + } + } + } + + /// Value used by watch polling. Disk-backed `note` bypasses AppState's FileState cache. + private func freshValue(_ key: DemoKey) -> String { + switch key { + case .note: + return (try? Self.readNoteFromDisk()) ?? get(key) + case .counter, .message, .flag: + return get(key) + } + } + + /// Read `note.json` without touching AppState's in-memory FileState cache. + /// + /// Mirrors AppState's non-Base64 FileState encoding: UTF-8 JSON via `JSONEncoder`. + public static func readNoteFromDisk() throws -> String { + let path = FileManager.defaultFileStatePath + let fileURL = URL(fileURLWithPath: path).appendingPathComponent("note.json") + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(String.self, from: data) + } catch { + throw APSError.persistenceFailed(key: .note) + } + } + + private func readForObservation(_ key: DemoKey) { + switch key { + case .counter: + _ = Application.state(\.counter).value + case .message: + _ = Application.state(\.message).value + case .flag: + _ = Application.state(\.flag).value + case .note: + _ = Application.fileState(\.note).value + } + } + + public nonisolated static func parseBool(_ value: String) -> Bool? { + switch value.lowercased() { + case "true", "1", "yes", "y", "on": return true + case "false", "0", "no", "n", "off": return false + default: return nil + } + } +} + +/// `@Sendable` flag for Observation `onChange` closures. +private final class ChangeFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func mark() { + lock.lock() + value = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private struct DumpSnapshot: Encodable { + let timestamp: Date + let keys: [DumpEntry] +} + +private struct DumpEntry: Encodable { + let key: String + let storage: String + let type: String + let value: String +} diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift new file mode 100644 index 0000000..2d3eb1b --- /dev/null +++ b/Tests/apsTests/APSTests.swift @@ -0,0 +1,290 @@ +import AppState +import Foundation +import XCTest +@testable import aps + +final class APSTests: XCTestCase { + override func setUp() async throws { + try await super.setUp() + + await MainActor.run { + Application.logging(isEnabled: false) + + // Isolate FileState under a unique temp directory for this test run. + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("aps-tests-\(UUID().uuidString)", isDirectory: true) + .path + FileManager.defaultFileStatePath = path + try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) + + Application.reset(\.counter) + Application.reset(\.message) + Application.reset(storedState: \.flag) + Application.reset(fileState: \.note) + } + } + + func testParseBool() { + XCTAssertEqual(StateStore.parseBool("true"), true) + XCTAssertEqual(StateStore.parseBool("YES"), true) + XCTAssertEqual(StateStore.parseBool("1"), true) + XCTAssertEqual(StateStore.parseBool("false"), false) + XCTAssertEqual(StateStore.parseBool("off"), false) + XCTAssertNil(StateStore.parseBool("maybe")) + } + + func testDemoKeyMetadata() { + XCTAssertEqual(DemoKey.counter.storage, "State") + XCTAssertEqual(DemoKey.flag.storage, "StoredState") + XCTAssertEqual(DemoKey.note.storage, "FileState") + XCTAssertEqual(DemoKey.counter.valueType, "Int") + XCTAssertEqual(DemoKey.allCases.count, 4) + XCTAssertTrue(DemoKey.note.detail.contains("FileState")) + } + + @MainActor + func testCounterRoundTrip() async throws { + let store = StateStore() + try store.set(.counter, value: "7") + XCTAssertEqual(store.get(.counter), "7") + try store.set(.counter, value: "42") + XCTAssertEqual(store.get(.counter), "42") + } + + @MainActor + func testMessageAndFlagRoundTrip() async throws { + let store = StateStore() + try store.set(.message, value: "hello") + XCTAssertEqual(store.get(.message), "hello") + + try store.set(.flag, value: "true") + XCTAssertEqual(store.get(.flag), "true") + try store.set(.flag, value: "0") + XCTAssertEqual(store.get(.flag), "false") + } + + @MainActor + func testNoteFileStateRoundTrip() async throws { + let store = StateStore() + try store.set(.note, value: "persisted note") + XCTAssertEqual(store.get(.note), "persisted note") + } + + @MainActor + func testInvalidCounterValue() async { + let store = StateStore() + do { + try store.set(.counter, value: "nope") + XCTFail("Expected invalid value error") + } catch let error as APSError { + XCTAssertEqual(error, .invalidValue(key: .counter, value: "nope")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + @MainActor + func testDumpIncludesKeysAndUsesDependency() async throws { + let store = StateStore() + try store.set(.counter, value: "3") + try store.set(.message, value: "hi") + + let json = try store.dump() + XCTAssertTrue(json.contains("\"key\" : \"counter\"")) + XCTAssertTrue(json.contains("\"value\" : \"3\"")) + XCTAssertTrue(json.contains("\"key\" : \"message\"")) + XCTAssertTrue(json.contains("\"storage\" : \"FileState\"")) + XCTAssertTrue(json.contains("timestamp")) + } + + @MainActor + func testJSONCodingDependency() async throws { + let coding = Application.dependency(\.jsonCoding) + let encoded = try coding.encodePretty(["ok": true]) + XCTAssertTrue(encoded.contains("true")) + } + + @MainActor + func testResetRestoresInitialValues() async throws { + let store = StateStore() + try store.set(.counter, value: "9") + try store.set(.message, value: "x") + try store.set(.flag, value: "true") + try store.set(.note, value: "n") + + store.reset(.counter) + store.reset(.message) + store.reset(.flag) + store.reset(.note) + + XCTAssertEqual(store.get(.counter), "0") + XCTAssertEqual(store.get(.message), "") + XCTAssertEqual(store.get(.flag), "false") + XCTAssertEqual(store.get(.note), "") + } + + @MainActor + func testResetAll() async throws { + let store = StateStore() + try store.set(.counter, value: "5") + try store.set(.note, value: "keep?") + store.resetAll() + XCTAssertEqual(store.get(.counter), "0") + XCTAssertEqual(store.get(.note), "") + } + + @MainActor + func testWatchDetectsInProcessStateChange() async throws { + let store = StateStore() + try store.set(.counter, value: "1") + + var seen: [String] = [] + store.watchBlocking( + .counter, + pollInterval: 0.05, + shouldContinue: { seen.count < 2 } + ) { value in + seen.append(value) + if value == "1" { + try? store.set(.counter, value: "2") + } + } + + XCTAssertEqual(seen, ["1", "2"]) + } + + @MainActor + func testWatchDetectsFileStateChange() async throws { + let store = StateStore() + try store.set(.note, value: "before") + + var seen: [String] = [] + store.watchBlocking( + .note, + pollInterval: 0.05, + shouldContinue: { seen.count < 2 } + ) { value in + seen.append(value) + if value == "before" { + try? store.set(.note, value: "after") + } + } + + XCTAssertEqual(seen, ["before", "after"]) + } + + @MainActor + func testWatchDetectsExternalFileStateWrite() async throws { + // Simulate another process: write note.json without updating AppState's cache. + let store = StateStore() + try store.set(.note, value: "before") + let path = FileManager.defaultFileStatePath + + var seen: [String] = [] + store.watchBlocking( + .note, + pollInterval: 0.05, + shouldContinue: { seen.count < 2 } + ) { value in + seen.append(value) + if value == "before" { + // Same on-disk format AppState uses for non-Base64 FileState. + let data = try? JSONEncoder().encode("changed") + let url = URL(fileURLWithPath: path).appendingPathComponent("note.json") + try? data?.write(to: url) + } + } + + XCTAssertEqual(seen, ["before", "changed"]) + } + + @MainActor + func testNoteUsesInjectedFileStatePath() async throws { + let path = FileManager.defaultFileStatePath + XCTAssertTrue(path.contains("aps-tests-"), "setUp must inject a temp FileState path") + + let store = StateStore() + try store.set(.note, value: "isolated") + + let fileURL = URL(fileURLWithPath: path).appendingPathComponent("note.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + + let homeNote = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".aps/note.json") + // Do not require ~/.aps to be absent globally; just ensure this write landed in temp. + XCTAssertNotEqual(fileURL.path, homeNote.path) + XCTAssertEqual(try StateStore.readNoteFromDisk(), "isolated") + } + + @MainActor + func testClockDependencyIsInjectable() async throws { + let clock = Application.dependency(\.clock) + let before = clock.now + XCTAssertLessThanOrEqual(before.timeIntervalSinceNow, 0) + } + + @MainActor + func testInvalidFlagValue() async { + let store = StateStore() + do { + try store.set(.flag, value: "maybe") + XCTFail("Expected invalid value error") + } catch let error as APSError { + XCTAssertEqual(error, .invalidValue(key: .flag, value: "maybe")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + @MainActor + func testFlagPersistsAcrossStateStoreInstances() async throws { + let writer = StateStore() + try writer.set(.flag, value: "true") + XCTAssertEqual(writer.get(.flag), "true") + + let reader = StateStore() + XCTAssertEqual(reader.get(.flag), "true") + + reader.reset(.flag) + XCTAssertEqual(StateStore().get(.flag), "false") + } + + @MainActor + func testProcessLocalStateKeysDoNotClaimCrossProcessPersistence() async throws { + // Document the contract: State keys are process-local. A fresh Application + // reset (as in setUp) restores initials; this test locks that expectation. + let store = StateStore() + try store.set(.counter, value: "99") + try store.set(.message, value: "ephemeral") + XCTAssertEqual(store.get(.counter), "99") + XCTAssertEqual(store.get(.message), "ephemeral") + + Application.reset(\.counter) + Application.reset(\.message) + XCTAssertEqual(store.get(.counter), "0") + XCTAssertEqual(store.get(.message), "") + } + + func testDemoKeyHelpSummaryFormat() { + for key in DemoKey.allCases { + let parts = key.helpSummary.split(separator: "\t") + XCTAssertEqual(parts.count, 3, "Expected key/type/storage columns for \(key)") + XCTAssertEqual(String(parts[0]), key.rawValue) + XCTAssertFalse(key.detail.isEmpty) + } + } + + func testAPSErrorDescriptionsAreActionable() { + let invalid = APSError.invalidValue(key: .counter, value: "nope") + XCTAssertTrue(invalid.description.contains("counter")) + XCTAssertTrue(invalid.description.contains("Int")) + + let unknown = APSError.unknownKey("wat") + XCTAssertTrue(unknown.description.contains("wat")) + XCTAssertTrue(unknown.description.contains("counter")) + + let persistence = APSError.persistenceFailed(key: .note) + XCTAssertTrue(persistence.description.contains("note")) + XCTAssertTrue(persistence.description.contains("persist")) + } +} diff --git a/fledge.toml b/fledge.toml new file mode 100644 index 0000000..0917757 --- /dev/null +++ b/fledge.toml @@ -0,0 +1,16 @@ +# fledge.toml: aps CLI lifecycle tasks +# Docs: https://github.com/CorvidLabs/fledge + +[tasks] +build = "swift build" +test = "swift test" +release-build = "swift build -c release" +smoke = "./Scripts/smoke.sh" +# Keep specsync out of lanes.verify: Trust/SpecSync run it as a top-level gate +# so verification commands cannot recurse into specsync check. +spec = "specsync check" +check = "fledge run build && fledge run test && fledge run smoke && fledge run spec" + +[lanes.verify] +description = "Native quality gate (build, test, smoke)" +steps = ["build", "test", "smoke"] diff --git a/specs/aps-cli/aps-cli.spec.md b/specs/aps-cli/aps-cli.spec.md new file mode 100644 index 0000000..5529507 --- /dev/null +++ b/specs/aps-cli/aps-cli.spec.md @@ -0,0 +1,105 @@ +--- +module: aps-cli +version: 8 +status: active +files: + - Sources/aps/Aps.swift + - Sources/aps/DemoKey.swift +db_tables: [] +depends_on: + - state-store +--- + +# APS CLI + +## 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. + +## Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum (`CaseIterable`, `ExpressibleByArgument`, `Sendable`). | +| `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`. | +| `unknownKey` | Unknown demo key token. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk-backed key did not persist after write. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState`). | +| `valueType` | Human value type (`Int` / `String` / `Bool`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | +| `description` | Actionable error text for humans and ValidationError bridging. | + +Command tree (informational): `Aps` is the `@main` root (`ParsableCommand`) with get, set, watch, dump, keys, and reset / reset --all. CLI `boot()` calls `APSPaths.configure()`. + +## Invariants + +1. The CLI entry point runs on the real main thread so AppState + `notifyChange()` assertions hold on Linux and macOS. +2. stdout for `get` / `set` / `watch` / `reset ` is only the value line(s); + help and errors use ArgumentParser defaults. +3. `State` keys are process-local; a new process must not be expected to retain + `counter` or `message`. +4. `watch` must flush each printed value immediately when stdout is not a TTY. +5. `keys` and `--help` do not mutate application state. + +## Behavioral Examples + +``` +Given a fresh process +When `aps set counter 3` runs +Then it prints `3` and exits 0. +``` + +``` +Given `aps set note hello` succeeded in process A +When process B runs `aps get note` +Then it prints `hello` (FileState persistence). +``` + +``` +Given `aps set counter nope` +When the command finishes +Then it exits non-zero with an invalid-value error naming `counter` and `Int`. +``` + +``` +Given `aps watch note` is running +When another process runs `aps set note changed` +Then the watcher prints `changed` within one poll interval. +``` + +## Error Cases + +- Unknown `DemoKey` token: ArgumentParser rejects before `run()`. +- Non-integer `counter` value: `APSError.invalidValue` -> ValidationError. +- Non-boolean `flag` value: `APSError.invalidValue` -> ValidationError. +- `reset` with neither a key nor `--all`: ValidationError. +- `reset` with both a key and `--all`: ValidationError. +- Failed `note` disk persistence: `APSError.persistenceFailed` -> ValidationError. + +## Dependencies + +- `ArgumentParser` for the command tree +- AppState (via `StateStore`) for typed state and dependencies +- Foundation for FileHandle / RunLoop / process paths + +## Change Log + +- 1: Initial CLI contract for get/set/watch/dump/keys/reset over the fixed demo schema. +- 2: Explicit export inventory for SpecSync active-contract checks (`DemoKey`, `APSError`). +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | diff --git a/specs/aps-cli/context.md b/specs/aps-cli/context.md new file mode 100644 index 0000000..58a8c1e --- /dev/null +++ b/specs/aps-cli/context.md @@ -0,0 +1,3 @@ +# Context - APS CLI + +The executable surface dogfoods AppState outside SwiftUI. It stays small: fixed demo keys, no plugin system, no network API. diff --git a/specs/aps-cli/requirements.md b/specs/aps-cli/requirements.md new file mode 100644 index 0000000..a618710 --- /dev/null +++ b/specs/aps-cli/requirements.md @@ -0,0 +1,50 @@ +--- +spec: aps-cli.spec.md +--- + +# Requirements - APS CLI + +## Functional + +### REQ-aps-cli-001 + +The CLI SHALL expose get, set, watch, dump, keys, and reset over the fixed `DemoKey` schema covering `counter`, `message`, `flag`, and `note`. + +Acceptance Criteria +- `aps --help` lists those subcommands. +- `DemoKey` includes only those four cases and exposes `storage`, `valueType`, `helpSummary`, and `detail`. + +### REQ-aps-cli-002 + +`set` SHALL reject values that cannot parse to the key's type and exit non-zero via `APSError.invalidValue`. + +Acceptance Criteria +- Non-integer `counter` values fail with an invalid-value message. +- Non-boolean `flag` values fail with an invalid-value message. +- `APSError.description` names the key and expected type. + +### REQ-aps-cli-003 + +Process-local `State` keys SHALL not be required to persist across process boundaries. + +Acceptance Criteria +- `counter` and `message` are documented and tested as process-local. +- `flag` (`StoredState`) and `note` (`FileState`) persist across processes after a successful set. + +### REQ-aps-cli-004 + +`watch` SHALL print the current value first and flush subsequent distinct values promptly, including cross-process `FileState` writes to `note`. + +Acceptance Criteria +- The first emitted line is the current value. +- Non-TTY stdout still surfaces each change without waiting for process exit. +- An external write to `note.json` is observed within one poll interval without relying on AppState's FileState cache. + +### REQ-aps-cli-005 + +`APSError` SHALL cover `unknownKey`, `invalidValue`, `encodingFailed`, `decodingFailed`, and `persistenceFailed`. + +Acceptance Criteria +- Each case has an actionable `description`. +- `set note` surfaces `persistenceFailed` when the on-disk value does not match after write. + diff --git a/specs/aps-cli/tasks.md b/specs/aps-cli/tasks.md new file mode 100644 index 0000000..23a3def --- /dev/null +++ b/specs/aps-cli/tasks.md @@ -0,0 +1,6 @@ +# Tasks - APS CLI + +- [x] ArgumentParser command tree for get/set/watch/dump/keys/reset +- [x] DemoKey fixed schema with storage metadata +- [x] Main-thread AppState access for Linux/macOS notifyChange safety +- [x] Smoke script covering persisted keys across processes diff --git a/specs/aps-cli/testing.md b/specs/aps-cli/testing.md new file mode 100644 index 0000000..5632227 --- /dev/null +++ b/specs/aps-cli/testing.md @@ -0,0 +1,5 @@ +# Testing - APS CLI + +- Unit: DemoKey metadata, parseBool, invalid set values +- Integration: StateStore round-trips via `@testable import aps` +- Smoke: `Scripts/smoke.sh` for flag/note persistence and reset diff --git a/specs/state-store/context.md b/specs/state-store/context.md new file mode 100644 index 0000000..9d6b438 --- /dev/null +++ b/specs/state-store/context.md @@ -0,0 +1,3 @@ +# Context - State Store + +StateStore is the non-UI AppState facade used by the CLI. It owns persistence quirks (Linux UserDefaults flush) and Observation-based watching. diff --git a/specs/state-store/requirements.md b/specs/state-store/requirements.md new file mode 100644 index 0000000..cf856a4 --- /dev/null +++ b/specs/state-store/requirements.md @@ -0,0 +1,43 @@ +--- +spec: state-store.spec.md +--- + +# Requirements - State Store + +## Functional + +### REQ-state-store-001 + +`StateStore` SHALL read and write demo keys through AppState Application extensions on the main actor via `init`, `get`, and `set`, without overwriting an injected `FileManager.defaultFileStatePath`. + +Acceptance Criteria +- `get`/`set` round-trip `counter`, `message`, `flag`, and `note`. +- Mutating paths are MainActor-isolated. +- `init` loads dependencies only; CLI `boot()` (or tests) configure FileState paths. + +### REQ-state-store-002 + +`StateStore` SHALL inject real `APSClock` / `SystemAPSClock` (`now`) and `JSONCoding` (`encodePretty`, `decode`) dependencies for `dump` output. + +Acceptance Criteria +- `dump` JSON includes every `DemoKey` and a timestamp. +- Dependencies are loaded via `Application.dependency` / `@AppDependency`. + +### REQ-state-store-003 + +Writing `flag` SHALL flush UserDefaults so Linux short-lived processes persist StoredState; writing `note` SHALL verify the on-disk value and throw `APSError.persistenceFailed` when persistence fails; `reset` / `resetAll` restore initials. + +Acceptance Criteria +- After `set(.flag, "true")`, a new `StateStore` instance observes true. +- After a successful `set(.note, ...)`, `readNoteFromDisk()` returns the same value. +- `reset(.flag)` restores false and flushes. + +### REQ-state-store-004 + +`watchBlocking` SHALL combine Observation with RunLoop polling and honor `shouldContinue`; for `note`, polling SHALL read the file directly so cross-process writes are visible despite AppState FileState caching; `parseBool` accepts common truthy/falsey tokens. + +Acceptance Criteria +- In-process `State` mutations are observed. +- External writes to `note.json` are observed without updating AppState's cache. +- `shouldContinue` false stops the loop without requiring Ctrl-C. + diff --git a/specs/state-store/state-store.spec.md b/specs/state-store/state-store.spec.md new file mode 100644 index 0000000..1de7a04 --- /dev/null +++ b/specs/state-store/state-store.spec.md @@ -0,0 +1,102 @@ +--- +module: state-store +version: 8 +status: active +files: + - Sources/aps/StateStore.swift + - Sources/aps/DemoState.swift + - Sources/aps/Dependencies.swift +db_tables: [] +depends_on: [] +--- + +# State Store + +## Purpose + +`StateStore` is the AppState-facing service used by the CLI. It reads and writes +the fixed demo keys through Application extensions, injects real dependencies +with `@AppDependency`, and provides dump / watch / reset helpers suitable for +non-UI use. + +## Public API + +| Export | Description | +|--------|-------------| +| `StateStore` | MainActor AppState facade used by the CLI. | +| `APSClock` | Clock protocol for dump timestamps. | +| `SystemAPSClock` | Production `APSClock` backed by `Date()`. | +| `JSONCoding` | Shared pretty JSON helpers. | +| `init` | Loads clock/jsonCoding dependencies without forcing `~/.aps`. | +| `get` | Return the string form of a demo key. | +| `set` | Parse and write; throw `APSError.invalidValue` or `persistenceFailed` on failure. | +| `reset` | Restore one key to its AppState initial value. | +| `resetAll` | Restore every demo key. | +| `dump` | Pretty JSON snapshot using `@AppDependency` clock + jsonCoding. | +| `watchBlocking` | Observation + RunLoop poll loop; `note` polls via direct disk read. | +| `parseBool` | Accept true/false/1/0/yes/no/on/off (case-insensitive). | +| `now` | Current `Date` from an `APSClock`. | +| `encodePretty` | Encode an `Encodable` value as pretty UTF-8 JSON text. | +| `decode` | Decode a `Decodable` value from UTF-8 JSON text. | +| `readNoteFromDisk` | Read `note.json` without touching AppState's FileState cache. | + +Application demo surface (informational): `Application.counter` / `message` / `flag` / `note` / `clock` / `jsonCoding`. `APSPaths.configure()` is invoked from CLI `boot()`, not `StateStore.init`. + +## Invariants + +1. All mutating AppState access happens on the main thread / MainActor. +2. Writing `flag` calls `UserDefaults.standard.synchronize()` so Linux flushes + before process exit. +3. `dump()` includes every `DemoKey` plus an ISO-8601 `timestamp`. +4. `watchBlocking` emits the current value first, then subsequent distinct values. +5. Dependencies are real services, not fake stubs used only for wiring demos. + +## Behavioral Examples + +``` +Given a StateStore on a clean Application +When set(.counter, value: "7") then get(.counter) +Then the result is "7". +``` + +``` +Given set(.flag, value: "true") +When a new process constructs StateStore and get(.flag) +Then the result is "true" (StoredState persistence after synchronize). +``` + +``` +Given watchBlocking(.counter, shouldContinue: { seen.count < 2 }) +When onChange receives "1" and sets counter to "2" +Then seen equals ["1", "2"]. +``` + +``` +Given dump() after setting message to "hi" +When decoding the JSON +Then keys include message with value "hi" and a timestamp field exists. +``` + +## Error Cases + +- `set(.counter, value: "nope")` throws `APSError.invalidValue`. +- `set(.flag, value: "maybe")` throws `APSError.invalidValue`. +- JSONCoding encode/decode failures surface as `APSError.encodingFailed` / + `decodingFailed` when UTF-8 conversion fails. + +## Dependencies + +- AppState (`Application`, `State`, `StoredState`, `FileState`, `@AppDependency`) +- Observation (`withObservationTracking`) for in-process watch delivery +- Foundation (`UserDefaults`, `RunLoop`, `JSONEncoder`) + +## Change Log + +- 1: Initial StateStore / Application demo-state contract for the aps CLI. +- 2: Explicit export inventory for SpecSync active-contract checks. +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | diff --git a/specs/state-store/tasks.md b/specs/state-store/tasks.md new file mode 100644 index 0000000..df8f8c7 --- /dev/null +++ b/specs/state-store/tasks.md @@ -0,0 +1,6 @@ +# Tasks - State Store + +- [x] Application demo keys: State, StoredState, FileState +- [x] APSClock and JSONCoding dependencies +- [x] dump / reset / watchBlocking helpers +- [x] UserDefaults.synchronize after flag writes diff --git a/specs/state-store/testing.md b/specs/state-store/testing.md new file mode 100644 index 0000000..f1a1ec5 --- /dev/null +++ b/specs/state-store/testing.md @@ -0,0 +1,6 @@ +# Testing - State Store + +- Round-trip tests for each DemoKey +- reset and resetAll restore initials +- watchBlocking in-process and FileState change detection +- dump includes dependency-driven timestamp and all keys