akm-cli 0.9.29 → 0.9.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +10 -10
- package/ROADMAP.md +112 -0
- package/dist/commands/improve/improve-cli.js +33 -12
- package/dist/commands/sources/plugin-upgrade.js +46 -23
- package/dist/core/file-lock.js +10 -1
- package/dist/llm/client.js +33 -3
- package/dist/scripts/akm-migrate-node.js +5 -1
- package/dist/scripts/akm-migrate.js +5 -1
- package/docs/README.md +42 -41
- package/docs/agents/README.md +15 -0
- package/docs/agents/agent-install.md +14 -0
- package/docs/architecture/README.md +67 -0
- package/docs/architecture/adapters.md +272 -0
- package/docs/architecture/akm-architecture-decision-history.md +2055 -0
- package/docs/architecture/akm-core-principles.md +126 -0
- package/docs/architecture/architecture.md +579 -0
- package/docs/architecture/comparisons/claude-code-vs-akm-workflows-full.md +550 -0
- package/docs/architecture/decisions/0001-no-interpolation-attached-structured-context.md +72 -0
- package/docs/architecture/decisions/0002-unit-reuse-and-input-hash-scope.md +142 -0
- package/docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md +189 -0
- package/docs/architecture/decisions/0004-task-input-contract-and-flag-coercion.md +97 -0
- package/docs/architecture/decisions/0005-task-result-vocabulary-and-legacy-read-mapping.md +108 -0
- package/docs/architecture/decisions/0006-task-source-version-routing.md +124 -0
- package/docs/architecture/decisions/0007-workflow-composition-bounds.md +83 -0
- package/docs/architecture/decisions/0008-task-binding-normalization.md +88 -0
- package/docs/architecture/decisions/0009-child-run-publication-column-parity.md +80 -0
- package/docs/architecture/decisions/0010-driver-protocol-cut.md +36 -0
- package/docs/architecture/decisions/0011-engine-run-loop-invariants.md +125 -0
- package/docs/architecture/decisions/README.md +62 -0
- package/docs/architecture/improvement.md +440 -0
- package/docs/architecture/internals/classification.md +154 -0
- package/docs/architecture/internals/fresh-host-rebuild-runbook.md +40 -0
- package/docs/architecture/internals/functional-contract-patterns.md +340 -0
- package/docs/architecture/internals/health-advisories.md +110 -0
- package/docs/architecture/internals/improve-workflow.md +713 -0
- package/docs/architecture/internals/indexing.md +498 -0
- package/docs/architecture/internals/registry-network-boundary.md +57 -0
- package/docs/architecture/internals/search.md +249 -0
- package/docs/architecture/internals/storage-locations.md +943 -0
- package/docs/architecture/persisted-data-compat.md +59 -0
- package/docs/architecture/reviews/env-secret-access.md +319 -0
- package/docs/architecture/runtime-boundary-design.md +153 -0
- package/docs/architecture/specs/0.9.0-close-out-plan.md +361 -0
- package/docs/architecture/specs/0.9.0-decisions.md +528 -0
- package/docs/architecture/specs/0.9.0-docs-code-drift-register.md +745 -0
- package/docs/architecture/specs/0.9.0-open-items-register.md +135 -0
- package/docs/architecture/specs/0.9.0-public-api-issue-backlog.md +740 -0
- package/docs/architecture/specs/0.9.0-release-surface-review.md +636 -0
- package/docs/architecture/specs/agent-command-engine-model-design.md +513 -0
- package/docs/architecture/specs/akm-0.9.0-bundle-adapter-spec.md +501 -0
- package/docs/architecture/specs/akm-0.9.0-ref-grammar-decision.md +291 -0
- package/docs/architecture/specs/akm-format-neutral-bundle-workspace-spec.md +2059 -0
- package/docs/architecture/specs/di-seams-plan.md +539 -0
- package/docs/architecture/specs/driver-protocol-keep-or-cut.md +275 -0
- package/docs/architecture/specs/fact-asset-type.md +142 -0
- package/docs/architecture/specs/improve-collapse-churn-detector-design.md +634 -0
- package/docs/architecture/specs/okf-support.md +148 -0
- package/docs/architecture/specs/ref.md +329 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/README.md +72 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/findings-consolidated.md +152 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/library-eval-alternatives.md +352 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/library-eval-bunqueue.md +300 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/library-eval-openworkflow.md +402 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/opus-review-1.md +758 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/opus-review-2.md +645 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/opus-review-3.md +602 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r1-adversarial.md +27 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r1-architecture.md +27 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r1-coverage.md +74 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r1-security.md +15 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r2-adversarial.md +132 -0
- package/docs/architecture/specs/reviews/task-workflow-unification/panel-r2-verification.md +147 -0
- package/docs/architecture/specs/stash-conventions-code-spec.md +275 -0
- package/docs/architecture/specs/stash-organization-conventions.md +377 -0
- package/docs/architecture/specs/task-workflow-format-unification.md +721 -0
- package/docs/architecture/specs/workflow-engine-buy-vs-build.md +231 -0
- package/docs/architecture/specs/workflow-format-unification.md +437 -0
- package/docs/architecture/testing/manual-testing-checklist.md +3420 -0
- package/docs/architecture/testing/okf-v0.2-conformance-runbook.md +924 -0
- package/docs/architecture/testing/task-workflow-manual-tests.md +1817 -0
- package/docs/architecture/testing/testing-workflow.md +446 -0
- package/docs/architecture/workflow-engine.md +274 -0
- package/docs/guides/README.md +33 -0
- package/docs/guides/author-bundles.md +310 -0
- package/docs/guides/author-workflows.md +520 -0
- package/docs/guides/bundles.md +321 -0
- package/docs/guides/capture-knowledge.md +136 -0
- package/docs/guides/claude-code-vs-akm-workflows.md +30 -0
- package/docs/guides/concepts.md +93 -0
- package/docs/guides/discover-and-load.md +158 -0
- package/docs/guides/getting-started.md +240 -0
- package/docs/guides/improve-the-library.md +246 -0
- package/docs/guides/knowledge-management.md +18 -0
- package/docs/guides/recipes/headless-install.md +97 -0
- package/docs/guides/recipes/website-source.md +28 -0
- package/docs/guides/run-workflows.md +279 -0
- package/docs/guides/scheduling.md +123 -0
- package/docs/guides/use-with-any-agent.md +129 -0
- package/docs/guides/wikis.md +81 -0
- package/docs/maintainers/README.md +19 -0
- package/docs/maintainers/curate-workmap.md +336 -0
- package/docs/maintainers/eval.md +3 -0
- package/docs/maintainers/local-development.md +108 -0
- package/docs/maintainers/release-checklist.md +103 -0
- package/docs/migration/README.md +2 -2
- package/docs/migration/release-notes/0.0.13.md +1 -1
- package/docs/migration/release-notes/0.1.0.md +1 -1
- package/docs/migration/release-notes/0.2.0.md +1 -1
- package/docs/migration/release-notes/0.3.0.md +1 -1
- package/docs/migration/release-notes/0.5.0.md +1 -1
- package/docs/migration/release-notes/0.6.0.md +1 -1
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.3.md +2 -2
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +1 -1
- package/docs/migration/release-notes/0.9.0.md +1 -1
- package/docs/migration/release-notes/0.9.14.md +1 -1
- package/docs/migration/release-notes/0.9.15.md +1 -1
- package/docs/migration/release-notes/0.9.16.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +1 -1
- package/docs/migration/release-notes/0.9.19.md +1 -1
- package/docs/migration/release-notes/0.9.2.md +1 -1
- package/docs/migration/release-notes/README.md +27 -31
- package/docs/migration/v0.5-to-v0.6.md +759 -0
- package/docs/migration/v0.8-to-v0.9.md +1 -1
- package/docs/plans/0.9.2-agent-command-workflow-plan.md +423 -0
- package/docs/posts/README.md +30 -0
- package/docs/posts/akm-command-tour.md +495 -0
- package/docs/posts/akm-logo-sized.webp +0 -0
- package/docs/posts/belief-aware-memory-12.md +139 -0
- package/docs/posts/config-v2-lessons-16.md +191 -0
- package/docs/posts/improve-loop-autonomous-10.md +225 -0
- package/docs/posts/improve-pipeline-debugging-0803.md +179 -0
- package/docs/posts/intro-part-01.md +76 -0
- package/docs/posts/intro-part-02.md +221 -0
- package/docs/posts/intro-part-03.md +190 -0
- package/docs/posts/intro-part-04.md +221 -0
- package/docs/posts/llm-mode-reflect-13.md +166 -0
- package/docs/posts/openpalm-built-on-akm.md +86 -0
- package/docs/posts/progressive-disclosure-06.md +129 -0
- package/docs/posts/proposal-safety-net-15.md +112 -0
- package/docs/posts/release-0.5.0.md +247 -0
- package/docs/posts/release-0.6.0.md +117 -0
- package/docs/posts/release-0.7.0.md +159 -0
- package/docs/posts/release-0.8.0.md +332 -0
- package/docs/posts/scattered-skills-05.md +100 -0
- package/docs/posts/task-assets-persistent-workflows-11.md +222 -0
- package/docs/posts/team-skills-07.md +198 -0
- package/docs/posts/wiki-agent-knowledge-08.md +223 -0
- package/docs/posts/workflows-vaults-09.md +321 -0
- package/docs/product-surface.md +54 -0
- package/docs/reference/README.md +1 -2
- package/docs/reference/asset-types.md +168 -0
- package/docs/reference/cli.md +5 -6
- package/docs/reference/env-and-secrets.md +126 -0
- package/docs/reference/memory.md +124 -0
- package/docs/reference/refs.md +156 -0
- package/docs/reference/registry.md +489 -0
- package/docs/reference/website-sources.md +141 -0
- package/package.json +12 -15
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Persisted-Data Compatibility Contract
|
|
2
|
+
|
|
3
|
+
akm persists state in twelve-odd formats across releases: config, three SQLite
|
|
4
|
+
databases, task and workflow documents, native scheduler rows, and a few smaller JSON/text payloads. Each has grown its own
|
|
5
|
+
versioning and its own failure behavior. This document is the one place that
|
|
6
|
+
states what a reader owes data an earlier release wrote, and inventories how
|
|
7
|
+
close each format is to that contract today.
|
|
8
|
+
|
|
9
|
+
## The contract
|
|
10
|
+
|
|
11
|
+
A reader must tolerate anything an earlier release wrote: convert the old
|
|
12
|
+
shape in memory, warn once, and name `akm migrate apply` as the on-disk
|
|
13
|
+
cleanup path — the migrator is where an explicit, backed-up rewrite happens,
|
|
14
|
+
never a precondition for reading. The only refusal a reader is allowed is data
|
|
15
|
+
written by a release *newer* than itself, and that refusal must name the
|
|
16
|
+
remedy (upgrade akm). One format is a named exception: task sources
|
|
17
|
+
(`tasks/*.yml`) are read only in the current grammar, and an older file is
|
|
18
|
+
refused on its own, naming `akm migrate apply` (#987; see the Task source
|
|
19
|
+
row). Bundle content is never rewritten just to make it
|
|
20
|
+
readable again by an older or current binary. Every format-version bump must
|
|
21
|
+
ship with its old shape covered by the upgrade rehearsal gate
|
|
22
|
+
(`tests/integration/upgrade-rehearsal/`). No config object is strict: an unknown key at any depth is kept in memory
|
|
23
|
+
and named once, round-trips through ordinary writes, and is dropped by
|
|
24
|
+
`akm migrate apply`.
|
|
25
|
+
|
|
26
|
+
## Formats
|
|
27
|
+
|
|
28
|
+
| Format | Where written | Version marker | Older data | Newer data | Gate |
|
|
29
|
+
| --- | --- | --- | --- | --- | --- |
|
|
30
|
+
| `config.json` | `src/core/config/config.ts` | `configVersion` field | `configVersion` is read, never gated on (`readConfigVersion`, `src/core/config/config.ts`): a missing field or `"0.9.0"` — the only value akm has shipped — loads silently, and any other value is named once and read as `0.9.0`; like the `extraParams` keys, the legacy `stashDir`/`sources[]`/`installed` shape is converted rather than dropped — `migrateLegacySourceShape` (`src/core/config/legacy-source-shape-shim.ts`) folds it into `bundles`/`defaultBundle` in memory on every load; `akm migrate apply`'s `configFile` step (`normalizeConfigFile`) persists all of it — `configVersion: "0.9.0"`, the converted source layout, the lifted `extraParams`, unknown keys dropped — to the file once, under a backup | Same as older: the value is named once and the file is read as `0.9.0`; there is no `UNSUPPORTED_CONFIG_VERSION` refusal. `akm health`'s `binary-config-skew` advisory (`src/commands/health/config-skew.ts`) is what says "a newer akm wrote this config, upgrade this install" | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`); `tests/config-v09.test.ts` |
|
|
31
|
+
| `state.db` | `src/core/state/migrations.ts` (append-only `MIGRATIONS` registry, applied by `src/storage/sqlite-migrations.ts`) | `schema_migrations` ledger table | `openStateDatabase` opens one connection (mkdir, open, standard pragmas), reads the ledger on it, and runs every migration not yet applied forward in one transaction; a missing/empty/table-less file is simply a fresh database. Before a migration that drops schema (`018-drop-dead-lane-schema`, `028-improve-ledger`; `STATE_MIGRATION_SAFETY_BY_ID`) runs against a database that already has migrations applied, the file is copied beside itself to `state.db.pre-<migration id>.bak` via `VACUUM INTO` on the same connection | An older binary opening a `state.db` migrated by a newer akm continues with the schema it knows and warns once (`warnNewerStateLedger`, `src/core/state-db.ts`); it does not refuse; only a ledger that diverges from this akm's registry is refused (`assertMigrationLedger`) | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) |
|
|
32
|
+
| `index.db` | `src/storage/repositories/index-schema.ts` (`ensureSchema`; `index-entry-schema.ts` for the DDL and the layout constant) | `index_meta.version` = `CANONICAL_INDEX_DB_VERSION` (26), a layout marker | Readers (`openExistingDatabase` / `openReadonlyExistingDatabase`) serve it as-is and name `akm index` once (`checkIndexLayout`); the writable opener migrates in place — added columns via `ALTER TABLE`, a one-time FTS rebuild from `entries` for the layout-23 content-copy tables, and for layout 24 the drops of the sqlite-vec mirror `entries_vec` (when sqlite-vec loads; dropping a vec0 table needs it) and `entry_fragments_fts`, with `entries.search_text` hashed into `embed_hash` before the column is dropped so no vector is re-embedded, and for layout 25 the declared links (`asset_links`, #935) derived from each entry's stored `document_json` with no file read (only the directories holding workflows and tasks, whose targets no earlier layout stored, lose their incremental cursor and re-drain on the next `akm index`) — never dropping `embeddings`, `utility_scores*`, or `llm_enrichment_cache`. It drops the lazy-extraction queue `graph_extraction_queue`, retired in 0.9.17, and, on any layout, the LLM entity graph (`graph_meta`, `graph_files`, `graph_file_*`) and its `llm_enrichment_cache` rows, retired in 0.9.17-alpha.9 — an older release's `CREATE TABLE IF NOT EXISTS` recreates the graph tables (empty) if it ever opens this index again. It also deletes `llm_enrichment_cache` rows at the default `cache_variant` — the metadata-enhance pass, retired in 0.9.17-alpha.9 — on any layout; memory inference uses its own named variant and is unaffected. A migration leaves `index_meta.vacuumPending`, and the next `akm index` VACUUMs the freed pages. An `entries` table older than layout 21 has its entries-keyed tables recreated: one that lacks a column this release reads, or that still has a transitional column layout 21 removed (`entry_key`, `dir_path`, `stash_dir`, `entry_json`, `entry_type`), as the table 0.9.1 wrote does, with `document_json` empty on every row. A reader finds such a table unservable (`hasCurrentEntriesTable`), so a read rebuilds it inline. Only `SQLITE_CORRUPT` deletes and rebuilds the file (#865) | Refused by readers and the writable opener alike (`INDEX_SCHEMA_INCOMPATIBLE`, "Upgrade akm to use this index.", `newerIndexLayoutError`), leaving the file untouched: a newer layout may lack a column this akm reads, as layout 25 lacks `search_text` | `tests/integration/indexer/index-layout-migration.test.ts` (layout-23 and layout-24 fixtures); `tests/integration/previous-release-corpus.test.ts` (the layout-25 index 0.9.17-alpha.7 wrote, `index-v25.sql`, and the layout-20 index 0.9.1 wrote, `index-v20.sql`); `tests/integration/storage/index-generation-read-boundary.test.ts`; upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) |
|
|
33
|
+
| Task source (`tasks/*.yml`) | `src/tasks/source/parse-task-source.ts` | `version: 4` (`TASK_SOURCE_V4_VERSION`) | **The named exception to the contract (#987): the runtime reads only v4.** `version: 2` or `version: 3` throws `TASK_SCHEMA_VERSION_UNSUPPORTED`, and a declared `version: 4` document whose `schedule[]` still carries 0.9.15's retired `enabled` key throws `TASK_SOURCE_INVALID`; both messages name `akm migrate apply`, the one place the conversion happens (`scripts/akm-migrate/migrate/task-files.ts`, planning every v2/v3/v4 file straight to v4 with the pure planner `src/tasks/source/task-to-v4.ts`, rewriting under a backup; `akm upgrade` runs it after an install). It covers every stash the runtime reads tasks from: each enabled configured bundle, and the stash `AKM_BUNDLE_DIR` selects when no bundle names it; the `akm-task` probe still recognizes a root of v2/v3 task files, so one without a recorded adapter is found. Every caller reports the refusal for that one file: `akm task sync` keeps reconciling every other task and leaves the file's installed row as it is | Any other `version` throws `TASK_SCHEMA_VERSION_UNSUPPORTED` naming `akm migrate apply` | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`); `tests/integration/previous-release-corpus.test.ts` (each older shape is refused at runtime and converted by the migrator) |
|
|
34
|
+
| Workflow IR (frozen plans) | `src/workflows/freeze/freeze.ts`, read back in `src/workflows/runtime/run-plan.ts` (`readRunPlan`, `decodeWorkflowPlan`) | `irVersion` (`WORKFLOW_PLAN_VERSION`, 6) on the plan and in `plan_ir_version`; `plan_hash` alongside | Every workflow compiles to one plan type. `decodeWorkflowPlan` reads the irVersion 4 and 5 shapes (their `sourceReadSet` and host-identity fields included) as well as the current one, and keeps keys it does not know. Neither the version nor the hash gates a read: a stored plan that decodes runs whatever `irVersion` froze it, with one warning; one that cannot be decoded is abandoned by `akm workflow run` (status `failed`) with a message naming `akm workflow run <ref>` to start afresh — a status change, never an exception; `status`/`list`/`abandon` never read the plan | A newer akm's plan that still decodes runs the same way; one that does not decode is left untouched and refused with a message naming "Upgrade akm" (`readRunPlan`'s `newer`) | `tests/workflows/run-plan.test.ts`; `tests/integration/workflows/frozen-plan.test.ts` — the rehearsal builds no workflow run |
|
|
35
|
+
| Native scheduler rows (crontab, launchd plists, Task Scheduler) | `src/tasks/backends/cron.ts`, `launchd.ts`, `schtasks.ts`; the invocation grammar in `src/tasks/scheduler-invocation.ts` | The row's own invocation shape: current rows set their environment inline (a cron `VAR=value` prefix, a plist `EnvironmentVariables` entry, a PowerShell `$env:` assignment) — always `AKM_BUNDLE_DIR`, the syncing shell's working stash — and carry no `--scheduler-context`; 0.9.0 – 0.9.17-alpha.6 rows carry `--scheduler-context <descriptor>` holding the same values; older rows carry neither. A cron command over 1,000 bytes runs an akm wrapper script (`sh <log dir>/.akm-cron-wrapper-…sh`) that holds the same line | Every shape is listed and parsed (`parseScheduledInvocationArgv`; a spilled cron row from its wrapper script): a row inside akm's own `# akm:task` markers from before `--scheduler-context` (#881), and a `--scheduler-context` row, which keeps firing (the CLI applies its descriptor, below) until the next plain `akm task sync` rewrites it in place — an update that keeps the row's launcher and schedule, never an add or remove. The primary bundle proves a row by the `AKM_BUNDLE_DIR` it carries, inline or in its descriptor (#846); any other bundle owns rows by name. Rows that still carry the pre-0.9.17 workflow execution-evidence marker (`# akm:workflow-evidence` in the crontab, an `akm-workflow-evidence` plist comment or PowerShell literal) are listed like any other and rewritten once without it. Plain `akm task sync` keeps an installed row's own launcher (`installOptionsFor`, `src/tasks/scheduler-sync.ts`) and recomputes its environment; `akm task sync --rebind` repoints the launcher | A row inside akm's markers, `com.akm.task.` label namespace, or `\akm\` task folder whose argv this binary cannot parse (for example one a newer akm wrote) is not listed: sync leaves it alone unless an enabled source renders a row with the same native id, which then replaces it — the row is derived state and its source is the record. A crontab whose akm markers are malformed is refused unmodified (`parseBlocks`, `cron.ts`) | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`: the previous release's `--scheduler-context` rows fire under the candidate, then `task sync --dry-run` shows them only as updates); `tests/tasks-sync.test.ts` (`--scheduler-context` rows and spilled rows are updates, never adds) |
|
|
36
|
+
| Scheduler-context descriptor (`$DATA/tasks/context/<sha256>.json`, written by 0.9.0 – 0.9.17-alpha.6) | No longer written: a row carries its own environment (#987). Read by `src/tasks/scheduler-invocation.ts` (`readLegacySchedulerContext`) | `version: 1`; the environment key set | When a row that names one fires, the CLI reads the file as plain JSON and applies its `AKM_*_DIR` keys and, in one written before 0.9.17, `PATH`; nothing else about the file (name, mode, owner, link) is checked. Sync reads its `AKM_BUNDLE_DIR` to attribute the row. A plain `akm task sync` that reconciles the row rewrites it without the argument; a row a sync leaves as it is (its source failed, a `--bundle` sync did not cover it) keeps naming the file | A key this binary does not know is ignored. A file that is gone or is not JSON fails the run with `INVALID_CONFIG_FILE` naming `akm task sync`; sync cannot attribute such a row to the primary bundle and leaves it in place, and `akm task prune` reports it as `invalid-context` | `tests/tasks-scheduler-invocation.test.ts`; `tests/integration/scheduler-context-launcher.test.ts`; upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) fires the previous release's rows under the candidate before and after its plain `task sync` |
|
|
37
|
+
| Proposal `metadata_json` | `src/storage/repositories/proposals-repository.ts` | No explicit version field; presence/absence of the `changes` key | Rows from before the `changes` key existed (~89% of archived rows on real installs) are treated as a known legacy gap — decoded with an empty change list rather than thrown (`proposals-repository.ts:47-56`). 0.9.17-alpha.9 adds a consolidate pair-pass `retire` proposal's `changes[0].op: "delete"` (no `after`, already a valid `FileChangeOp`) and its `retirement` / `retiredArchive` / `promotionSource` / `promotionSourceHash` metadata keys, plus the retirement continuity check's `retirement.continuityRisk` (rule R3, item 1) — a person can accept a proposal carrying it by id even though it is excluded from bulk accept; an older reader's `validatePresentMetadata` does not enumerate these keys, so it neither inspects nor rejects them — the row decodes with them silently absent, same as any other field an older release predates. 0.9.19 adds `reviewHistory` (the rejections `akm proposal reopen` undid, oldest first, each with its gate verdict) the same way: a proposal reopened by 0.9.19 and read by an older release is an ordinary pending proposal with its history absent, and that release drops the key if it rewrites the row. The quality judge's evidence, `scores` and `judgeReason`, is added inside the existing `gateDecision` object (Unreleased): an older reader's `validatePresentMetadata` checks only the gate fields it knows and carries the object through whole, so the evidence survives a rewrite by an older release unread. An older release also counts a pending proposal's age (retention expiry, `--older-than`) from `createdAt`, so after a downgrade a reopened proposal can expire at the next `akm improve` run (a retire proposal never expires), or be swept by a scheduled `--older-than` bulk action or drain | 0.9.17-alpha.8 and earlier predate the retire proposal shape entirely — they were never taught to read a delete-primary change with no `after`. `akm proposal show` / `diff` / `drain` exit 70 on one, and drain's own nightly pre-pass failing on the first pending retire proposal it meets stops that run's auto-promotion for the whole stash, not just that one proposal. **Downgrade gate:** every pending retire proposal must be accepted or rejected (`akm proposal accept` / `reject`) before downgrading to 0.9.17-alpha.8 or earlier — once decided it is archived, out of the pending set `show`/`diff`/`drain` iterate, and no longer trips this. **Gap** otherwise (no code-level guard; this is operator guidance only). Downgrading also revives the starvation the second review round's B1 fixed forward-only: 0.9.17-alpha.8's `retrieval-scope.ts` counts a `consolidate-pair` ledger row as processed, so the pair pass's own nightly attempts crowd new material back out of every other improve lane's scope, the same way they did before B1 — measured on the real bundle, two alpha.9 nights left only 341 of 2,183 new-only assets still in scope once read under alpha.8. **Gap** here too (the ledger `source` string carries no version of its own; operator guidance only — do not downgrade across a pair-pass run without expecting this). | `tests/proposal-repository-pure.test.ts` — unit only; the rehearsal builds no proposal |
|
|
38
|
+
| `improve_ledger` rows | `src/storage/repositories/improve-ledger-repository.ts` | No version field of its own; the `content_hash` column (migration `029-improve-ledger-content-hash`) | A consolidate promotion row with no `content_hash` is held by its clock alone (`next_eligible_at`). 0.9.19 records `content_hash`, and no `next_eligible_at`, on a consolidate promotion once it is accepted or rejected, and holds the memory until its body hash differs (`isContentDrivenRow`); a promotion decided by an older release has no hash and keeps its old window (an accepted one none, a rejected one 7 days) | An older release reads a row by `next_eligible_at` alone and ignores the hash, so after a downgrade a memory whose promotion 0.9.19 decided is offered to the promote pass again at once, a rejected one sooner than the 7 days the older release would have held it; the hash stays on the row until the older release records its next attempt on that memory, which clears it. **Gap** (no code-level guard; operator guidance only) | `tests/integration/storage/improve-ledger-repository.test.ts` |
|
|
39
|
+
| `task_history` metadata | `src/storage/repositories/task-history-repository.ts` | `metadataVersion` (currently `2`) | An absent `metadataVersion` is treated as a legacy row (58% of rows written by prior releases) and decoded best-effort; unknown fields are dropped harmlessly rather than round-tripped (`task-history-repository.ts:61-69`) | A `metadataVersion` newer than this binary's `2` is decoded best-effort as version 2 with a warning, rather than rejected (`task-history-repository.ts:85-89`) | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) |
|
|
40
|
+
| Lock payloads | `src/core/file-lock.ts` | No version field; payload is a bare numeric pid string, or a JSON object carrying `pid` and optionally `launcherPid` (`extractLockIdentity`, `file-lock.ts`) | Any payload shape parses permissively: extra fields are ignored, a missing or unparseable `launcherPid` is simply absent, and a payload with no parseable `pid` probes as `stale` (`invalid_pid`, `probeLock`) and is reclaimed rather than rejected | Same permissive parse; a payload from a newer akm that adds fields is read the same way | `tests/integration/file-lock.test.ts` (describe "probeLock: launcherPid (#956)") — not the rehearsal, which does not contend locks |
|
|
41
|
+
| `.akm` residue | Documented in `docs/architecture/internals/storage-locations.md` | No version marker | Stale `.akm` directories or files from a previous layout are inert; nothing in current code reads or interprets them | N/A | none — gap (the closest coverage, `tests/migrate/dead-residue.test.ts`, tests the detection and removal that `akm migrate status` / `akm migrate apply` run, not that ordinary commands tolerate leftover `.akm` residue) |
|
|
42
|
+
|
|
43
|
+
## Adding a format or bumping one
|
|
44
|
+
|
|
45
|
+
1. Give the format an explicit version marker (a field, a table, a filename
|
|
46
|
+
convention) if it does not already have one — "no marker" is not a
|
|
47
|
+
compatibility strategy.
|
|
48
|
+
2. Write the reader so it converts an older marker in memory and warns once,
|
|
49
|
+
per the contract above. Reserve throwing for data from a version this
|
|
50
|
+
binary has never heard of, and name `akm migrate apply` (or the specific
|
|
51
|
+
rebuild command, e.g. `akm index`) in the error.
|
|
52
|
+
3. Add the old shape as a fixture to
|
|
53
|
+
`tests/integration/upgrade-rehearsal/` so a real previous-release
|
|
54
|
+
binary's output is exercised, not just a hand-written fixture.
|
|
55
|
+
4. If the format is `config.json`, nothing more: unknown keys at any depth
|
|
56
|
+
are kept and named once by the reader (`unknownConfigKeyPaths`), and
|
|
57
|
+
`akm migrate apply` drops them.
|
|
58
|
+
5. Add or extend the row in the table above, including a `Gap:` note if the
|
|
59
|
+
code does not yet meet the contract — do not leave a mismatch undocumented.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Architecture Review: Runtime secret resolution for in-process source fetchers
|
|
2
|
+
|
|
3
|
+
**Repo:** `/home/user/akm` (read-only review)
|
|
4
|
+
**Subject:** How the X snapshot fetcher (`src/sources/snapshot-fetchers/x.ts`) — and, more generally, the provider `sync()` / bundle-update path — should read a bearer token from akm's secret store at runtime, given a ref like `secrets/x-bearer-token`, without (a) leaking the value into agent/LLM/log context or (b) forming a static or dynamic import cycle.
|
|
5
|
+
**Date:** 2026-08-02
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Executive summary
|
|
10
|
+
|
|
11
|
+
The root cause is a **Dependency Inversion violation**: the secret-store reader (`src/core/env-secret-ref.ts`) transitively imports the entire source-provider → fetcher-registry → fetcher subgraph (via `indexer/search/search-source.ts`), so any module inside that subgraph that reaches back to the store closes an import cycle the repo's ratchet forbids. The already-shipped fix is the right shape — a `resolveSecret?: (ref) => string | null` capability injected onto `FetcherContext` from callers positioned *above* the cycle (`src/sources/snapshot-fetchers/types.ts:43`) — but it is only wired in from the two command-layer entry points, leaving the provider-registry `sync()` / bundle-update path (`src/sources/providers/website.ts:26-32`) environment-variable-only. The recommended solution is to **formalize that existing seam as a first-class `SecretResolver` capability and thread it through the `sync()` path the same way it is already threaded through the `fetch()` path** — construct it once in `secret-seam.ts` (the sole sanctioned `env-secret-ref` importer, already outside the cycle) and pass it as data through `ensureSourceCaches → provider.sync → ensureWebsiteMirror`. This extends the shipped idiom rather than inventing a parallel one, adds no `env-secret-ref` import inside the cycle, and preserves the `secret run` value-containment discipline (same-frame header set, never returned upward, swallow-to-null).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 2. Current architecture
|
|
16
|
+
|
|
17
|
+
### 2.1 How a ref resolves to a value
|
|
18
|
+
|
|
19
|
+
A "ref" (`secrets/x-bearer-token`, `env/FOO`) is resolved to an **absolute path** — never to bytes — inside `src/core/env-secret-ref.ts`:
|
|
20
|
+
|
|
21
|
+
1. `parseSecretRef` / `parseEnvRef` parse the ref into an `AssetRef` (`{type, name, origin}`); bare names auto-qualify to `env/` or `secrets/`, and legacy `vault:` / colon spellings are rejected loudly, not translated. (`src/core/env-secret-ref.ts:75-153`)
|
|
22
|
+
2. `findEnvSource` locates which configured bundle source holds the ref by `fs.existsSync` probing candidate source paths, using `resolveSourceEntries(undefined, loadConfig())` + `resolveSourcesForOrigin`. (`src/core/env-secret-ref.ts:85-106`)
|
|
23
|
+
3. `resolveSecretPath` / `resolveEnvPath` compute the absolute path via `assetPathForName` + an `isWithin` traversal guard and return `{name, absPath, source}` only — they never open the file. (`src/core/env-secret-ref.ts:121-145,168-195`)
|
|
24
|
+
|
|
25
|
+
The **only** place raw secret bytes are read is `readValue` (`src/commands/env/secret.ts:102-108`), an `fs.readFileSync` wrapper with a documented no-log / no-stdout contract; `loadEnv` (`src/commands/env/env.ts:89-100`) is the analogous whole-file reader for `.env`.
|
|
26
|
+
|
|
27
|
+
For fetchers specifically, the read path is `resolveSecretFromStore(ref)` in `src/sources/snapshot-fetchers/secret-seam.ts:23-31`: it calls `resolveSecretPath`, `fs.existsSync`, `fs.readFileSync(...).trim()`, and returns `null` for *any* failure — the underlying error (which can embed filesystem paths) is deliberately swallowed.
|
|
28
|
+
|
|
29
|
+
### 2.2 How values are kept out of context
|
|
30
|
+
|
|
31
|
+
akm keeps secret/env values out of agent/LLM/log surfaces with two layers:
|
|
32
|
+
|
|
33
|
+
- **Structural boundary.** The only sanctioned "use the value" path is `secret run`, which reads the file and injects it as `$VAR` into a `spawnSync` child's env — the read string never appears in a return value, in `output()`, or in the audit event (`src/commands/env/secret-cli.ts:213-231`; the `secret_access` event records ref + var name only, "never the value", `:221-226`). `env run` does the identical thing for whole `.env` files (`src/commands/env/env-cli.ts:280-301`). `env export` is the one path that writes a raw value, to a mode-`0600` file, never stdout (`src/commands/env/env-cli.ts:211-237`). `format-exempt.ts:53-67` formally classifies these as non-envelope commands so `--format` can't leak a value.
|
|
34
|
+
- **Defense-in-depth scrubber.** `src/core/redaction.ts` provides `collectSensitiveValues` / `redactSensitiveText` / `redactSensitiveValue` (`:193-398`) plus a pattern-based `redactCredentialPatterns` (`:307-341`, catches `Bearer` tokens, `sk-`/`key-` keys) invoked at every real output boundary — LLM error bodies (`src/llm/client.ts`), workflow reports (`src/workflows/exec/report.ts`), agent dispatch (`src/integrations/agent/runner-dispatch.ts`), proposal/improve output.
|
|
35
|
+
|
|
36
|
+
Today `x.ts` obeys the structural rule: `resolveXBearerToken` (`src/sources/snapshot-fetchers/x.ts:79-87`) returns the token to `fetch()`, which passes it straight into an `Authorization: Bearer` header inside `xApiJson`'s call frame (`x.ts:111-118`) and never onto a `FetcherContext` field, a `WikiSnapshotResult`, or an event.
|
|
37
|
+
|
|
38
|
+
### 2.3 The import cycle
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
src/core/env-secret-ref.ts
|
|
42
|
+
│ import { resolveSourceEntries } (env-secret-ref.ts:18)
|
|
43
|
+
▼
|
|
44
|
+
src/indexer/search/search-source.ts
|
|
45
|
+
│ import { resolveSourceProviderFactory } (search-source.ts:13)
|
|
46
|
+
│ import "../../sources/providers/index" (search-source.ts:16, side-effect)
|
|
47
|
+
▼
|
|
48
|
+
src/sources/providers/index.ts
|
|
49
|
+
│ imports ./website (and ./filesystem, ./git-provider, ./npm)
|
|
50
|
+
▼
|
|
51
|
+
src/sources/providers/website.ts
|
|
52
|
+
│ import { ensureWebsiteMirror } from ../snapshot-fetchers/website-ingest
|
|
53
|
+
▼
|
|
54
|
+
src/sources/snapshot-fetchers/website-ingest.ts
|
|
55
|
+
│ import { loadWikiSnapshotFetchers } from ./registry
|
|
56
|
+
▼
|
|
57
|
+
src/sources/snapshot-fetchers/registry.ts ──▶ x.ts, rss.ts, … (the fetchers)
|
|
58
|
+
|
|
59
|
+
╭──────────────────── THE FORBIDDEN BACK-EDGE ────────────────────╮
|
|
60
|
+
│ If x.ts (or website.ts) imports core/env-secret-ref — directly, │
|
|
61
|
+
│ or via secret-seam.ts, or via a dynamic import() — the arrow │
|
|
62
|
+
│ closes back to the top and the cycle-ratchet rejects it. │
|
|
63
|
+
╰──────────────────────────────────────────────────────────────────╯
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`origin-resolve.ts` (`src/registry/origin-resolve.ts:5-9`) and `mutation-target.ts` (`src/core/mutation-target.ts:5-11`), both imported by `env-secret-ref.ts`, also reach `search-source.ts` — they reinforce the same vector rather than adding a second one. The pure helpers `env-secret-ref` imports (`config/config.ts`, `asset/*`) carry no provider/fetcher edges and are not part of the cycle.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 3. Why it's hard
|
|
71
|
+
|
|
72
|
+
### 3.1 The precise SOLID violation
|
|
73
|
+
|
|
74
|
+
`core/env-secret-ref.ts` is a low-level detail (how akm reads a secret file) that has been made to **depend on** a high-level policy module graph (the whole source-resolution / provider / fetcher subsystem) through the single edge at `env-secret-ref.ts:18`. A fetcher (`x.ts`) is a high-level consumer whose policy is "authorize this outbound request." Because the concrete store sits *upstream* of the fetcher in the import graph, the fetcher cannot depend on the concrete store without a cycle. This is a textbook **DIP** situation: the fix must make both the consumer and the store depend on an **abstraction** (a leaf type), with the concrete store **injected** from a composition root above the cycle.
|
|
75
|
+
|
|
76
|
+
### 3.2 Why each naive fix fails
|
|
77
|
+
|
|
78
|
+
| Attempt | Why it fails |
|
|
79
|
+
|---|---|
|
|
80
|
+
| **Static import** of `env-secret-ref` from `x.ts` | Closes the back-edge directly. Ratchet rejects. |
|
|
81
|
+
| **Lazy `import()`** of `env-secret-ref` (or `secret-seam`) from inside the cycle | Same cycle; the ratchet treats dynamic `import()` as **"cycle-laundering"** and rejects it too. (`x.ts:73-74` documents this.) |
|
|
82
|
+
| **Relocated reader module** | Moving the reader still leaves a module *inside* the subgraph importing something that transitively reaches `env-secret-ref` — the *importer edge* is what closes the cycle, so relocation-without-severing keeps the back-edge. |
|
|
83
|
+
| **Shipped `FetcherContext.resolveSecret` seam** (`types.ts:43`) | Architecturally correct — injected, not imported. But populated **only** by the two command-layer callers (`src/commands/read/knowledge.ts:145`, `src/commands/sources/source-add.ts:173`) via `resolveSecretFromStore`. |
|
|
84
|
+
|
|
85
|
+
### 3.3 The `sync()` gap
|
|
86
|
+
|
|
87
|
+
The provider-registry refresh path never populates the seam. `website.ts`'s registered factory closure calls `ensureWebsiteMirror(config, { requireStashDir, force, ...allowPrivateHosts })` with **no `resolveSecret` key** (`src/sources/providers/website.ts:26-32`), and structurally *cannot* import one: `website.ts` is itself inside the cycle. The full downstream plumbing already exists — `ensureWebsiteMirror` → `scrapeWebsiteToStash` → `fetchSnapshotViaRegistry` → `FetcherContext.resolveSecret` all thread an optional `resolveSecret` (`website-ingest.ts:194-233,273-290,374-385`) — the *only* missing piece is that nothing inside the providers/registry subgraph can construct or receive a real resolver to hand in as that option's value. `ensureSourceCaches` calls `provider.sync({ force })` with no secret parameter (`search-source.ts:383`), and its sole materializing caller `indexer.ts:688-689` passes only `{ force, materialize }`. So the bundle-update path is `X_BEARER_TOKEN`-env-only.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. Options considered
|
|
92
|
+
|
|
93
|
+
Four proposals were developed and adversarially critiqued. All four break the cycle correctly against the real edge list; they differ on idiom-fit, blast radius, and risk. Critic ranking: **P4 > P2 > P3 > P1**.
|
|
94
|
+
|
|
95
|
+
### 4.1 Proposal 1 — Secret-resolver runtime-registration port (service locator)
|
|
96
|
+
|
|
97
|
+
A zero-import leaf `src/core/secret-resolver-port.ts` holds a single registrable `SecretResolver` slot; a registration module (importing `secret-seam`) self-registers it, side-effect-imported from `cli.ts`; in-cycle consumers call `resolveRegisteredSecret(ref)`.
|
|
98
|
+
|
|
99
|
+
- **SOLID:** DIP inversion is real; SRP clean. But it introduces a **global mutable singleton / service locator** — an ambient dependency invisible in signatures — which is *not* a house idiom (akm uses context-object injection, not service location). Creates **two parallel seams** for one capability, which the subsystem map explicitly warned against.
|
|
100
|
+
- **Critic verdict: VIABLE, score 5 (lowest).** The "mirrors `registerSourceProvider`" framing is misleading: `registerSourceProvider` is safe because `search-source.ts` *eagerly* side-effect-imports `providers/index`, guaranteeing registration before any `sync()`. P1's registration module imports `env-secret-ref`, so `search-source` **cannot** import it — registration hangs off `cli.ts` alone with **no load-order guarantee**. Any entry that reaches `ensureSourceCaches` without going through `cli.ts` (tests, library embedding, a second binary, a workflow executor importing `search-source` directly) silently degrades to env-var-only. Requires a test-reset hook to avoid cross-test leakage.
|
|
101
|
+
|
|
102
|
+
### 4.2 Proposal 2 — `CredentialProvider` capability (opaque, replaces the field)
|
|
103
|
+
|
|
104
|
+
A pure-type leaf `src/sources/credential-provider.ts` defines `CredentialProvider { has(ref): boolean; authorizeBearer(ref, headers): void }` and `SourceProviderContext { credentials? }`. `FetcherContext.resolveSecret` is **replaced** by `credentials?`; `SourceProviderFactory` gains a 2nd `context?` arg; the value is injected from above.
|
|
105
|
+
|
|
106
|
+
- **SOLID:** Strongest value-containment of the four — `authorizeBearer` returns `void`, `has` returns `boolean`, so **there is no value to return upward**; the token never enters consumer scope as a standalone variable. Clean ISP (two focused methods) and DIP. **But** it deliberately violates the map's "extend not replace / reuse the field name" guidance: it renames the shipped field and changes factory arity, forcing lockstep migration of `x.ts`, `website-ingest` plumbing, two command sites, and all test doubles.
|
|
107
|
+
- **Critic verdict: VIABLE, score 7.** Compiles; cycle-break is real (leaf type is a graph sink). Principled long-term target *if* a breaking contract change is ever acceptable — today the replace-the-field churn is unjustified when P4 reaches the same consumers additively.
|
|
108
|
+
|
|
109
|
+
### 4.3 Proposal 3 — Leaf secret-read module with a provider-free enumerator
|
|
110
|
+
|
|
111
|
+
Split `env-secret-ref.ts` into a WRITE module and a new **leaf** `src/core/env-secret-read.ts` whose source enumeration (`secretSourceRoots`) is rebuilt from `bundlesToSourceEntries` + `lockContentRootFor` + `resolveStashDir` instead of `resolveSourceEntries` — **deleting** the `env-secret-ref → search-source` edge entirely.
|
|
112
|
+
|
|
113
|
+
- **SOLID:** Best SRP (separates read-resolution from write/mutation) and the only proposal that *removes* the load-bearing edge rather than routing a value around it. Verified the leaf's dependency set (`config/config`, `integrations/lockfile`, `core/common`, `asset-placement`, `resolve-ref`) has zero path to `search-source` or the providers subgraph. This is genuinely *not* the failed relocated-reader attempt, because it deletes the `resolveSourceEntries` call rather than moving it.
|
|
114
|
+
- **Critic verdict: VIABLE, score 6.** The decisive risk is an **OCP smell / second source-of-truth**: `secretSourceRoots` reimplements a slice of `resolveSourceEntries`, drops `deriveInstallations` origin matching (registryId-equality only), drops the `provider.path()` fallback for legacy/unlocked git-npm bundles, and skips website caches — so it can silently resolve a **different file** than the CLI would. That is exactly the resolver-mismatch class that got `secret path` / `secret remove` amputated (R-027 / D-49). Only safe if `secretSourceRoots` delegates to a shared lock-first helper also used by `resolveEntryContentDir`. Keep as a **future** structural cleanup, not the primary fix.
|
|
115
|
+
|
|
116
|
+
### 4.4 Proposal 4 — Threaded `SecretResolver` capability (recommended)
|
|
117
|
+
|
|
118
|
+
Promote the ad-hoc `resolveSecret?:(ref)=>string|null` into a first-class `SecretResolver` object, constructed once in `secret-seam.ts` (the sole sanctioned `env-secret-ref` importer, already outside the cycle) and threaded as an explicit **call-time parameter** through the `sync()` path (`ensureSourceCaches → provider.sync options → website.ts`) exactly as it is already threaded through the `fetch()` path. Closes the gap with **no registry, no dynamic import, no reader relocation, no new global**, and **extends** the shipped seam rather than replacing it.
|
|
119
|
+
|
|
120
|
+
- **SOLID:** SRP — the capability has one responsibility (`ref → value | null`); `secret-seam.ts` stays the single place that knows about `env-secret-ref`. OCP — a future provider kind (private git remote, authed npm registry) reads `options.secrets` in its own `sync()` with zero contract change. LSP — every `SecretResolver` substitute (store-backed, env-only stub, fixed-token fake) is interchangeable; `secrets?` stays optional (absent = documented env-var-only). ISP — single-method interface. DIP — this is the crux and the mechanism: in-cycle modules (`search-source`, `website.ts`, `x.ts`) depend on the `SecretResolver` **abstraction** (a leaf type in `provider.ts`), and the concrete binding is injected from composition roots above the cycle.
|
|
121
|
+
- **Critic verdict: RECOMMEND, score 8 (highest).** Compiles, cycle-break is real: `SecretResolver` goes in `src/sources/provider.ts`, a verified graph **sink** (it imports only type-only `config/config` and `sources/types`), so `search-source`/`website` importing a type creates no runtime back-edge; the concrete `storeSecretResolver` lives in `secret-seam.ts` (imported by nothing in the subgraph) and reaches `website.ts` as **plain data** on the sync options object handed down from `indexer.ts` (confirmed above the cycle). Value containment matches the `secret-cli.ts` bar.
|
|
122
|
+
- **One real weakness:** the `secrets` option is optional and *not* compiler-enforced, so a future call site that forgets to forward it silently degrades to env-var-only — the same failure class that produced today's gap, closed here but not eliminated for future callers. (Mitigated by the hybrid grafts below.)
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 5. Recommendation
|
|
127
|
+
|
|
128
|
+
**Adopt Proposal 4 (Threaded `SecretResolver` capability), with two cheap hardening grafts.** It is the minimal, additive, idiom-faithful fix: it extends the already-shipped `FetcherContext.resolveSecret` capability-injection seam down the `sync()` path instead of inventing a parallel mechanism, reaches both fetchers **and** provider `sync()`, and keeps the ratchet green because no `env-secret-ref` import lands inside the providers/registry/fetchers subgraph.
|
|
129
|
+
|
|
130
|
+
### 5.1 The abstraction (leaf type, a graph sink)
|
|
131
|
+
|
|
132
|
+
In `src/sources/provider.ts` (alongside `SourceProvider`; this module imports only type-only `config/config` and `sources/types`, so it stays a sink):
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
export interface SecretResolver {
|
|
136
|
+
/** Resolve a store ref → value or null. Never logs, never throws upward. */
|
|
137
|
+
resolveSecret(ref: string): string | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface SyncOptions {
|
|
141
|
+
force?: boolean;
|
|
142
|
+
secrets?: SecretResolver;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface SourceProvider {
|
|
146
|
+
readonly name: string;
|
|
147
|
+
readonly kind: SourceKind;
|
|
148
|
+
path(): string;
|
|
149
|
+
sync?(options?: SyncOptions): Promise<void>; // was: sync?(options?: { force?: boolean })
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`SourceProviderFactory` is unchanged (`(config: SourceConfigEntry) => SourceProvider`) — no factory arity change, unlike P2.
|
|
154
|
+
|
|
155
|
+
### 5.2 The single composition root (already outside the cycle)
|
|
156
|
+
|
|
157
|
+
In `src/sources/snapshot-fetchers/secret-seam.ts` (already the ONLY module importing `core/env-secret-ref`, and imported by nothing in the providers/registry/fetchers subgraph — verified: only `commands/read/knowledge.ts` and `commands/sources/source-add.ts` import it today):
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
export const storeSecretResolver: SecretResolver = { resolveSecret: resolveSecretFromStore };
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Graft (a) — belt-and-suspenders containment (from P2/`llm/client.ts`):** have this resolver register any resolved token into `redactSensitiveText` / `redactSensitiveValue`'s `sensitiveValues` set (`src/core/redaction.ts:193-398`), so that if a request-header ever reaches a serialized error/log surface the token is scrubbed. The structural never-returned-upward guarantee holds independently; this covers the residual transient `headers.Authorization` exposure that all four proposals share.
|
|
164
|
+
|
|
165
|
+
### 5.3 Threading through the `sync()` path (data, not imports)
|
|
166
|
+
|
|
167
|
+
`src/indexer/search/search-source.ts` — import the **type only**:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
export async function ensureSourceCaches(
|
|
171
|
+
config?: AkmConfig,
|
|
172
|
+
options?: { force?: boolean; materialize?: boolean; secrets?: SecretResolver },
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
// …
|
|
175
|
+
await provider.sync({ force, secrets: options?.secrets }); // was: provider.sync({ force }) (search-source.ts:383)
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`src/sources/providers/website.ts` sync() — the one missing line (imports the `SecretResolver` **type** only; the resolver arrives as data):
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
async sync(options?: SyncOptions) {
|
|
183
|
+
await ensureWebsiteMirror(config, {
|
|
184
|
+
requireStashDir: true,
|
|
185
|
+
force: options?.force,
|
|
186
|
+
resolveSecret: options?.secrets?.resolveSecret, // ← closes the gap; feeds the EXISTING plumbing
|
|
187
|
+
...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`FetcherContext.resolveSecret` (`types.ts:43`) and the entire `website-ingest.ts` `resolveSecret` plumbing (`:194-233,273-290,374-385`) are **unchanged** — the capability's `.resolveSecret` method feeds the existing field.
|
|
193
|
+
|
|
194
|
+
### 5.4 Wiring at the entry surfaces (all verified above the cycle)
|
|
195
|
+
|
|
196
|
+
- `src/indexer/indexer.ts:688-689` (full-index / hydrate — the env-var-only path today): pass `secrets: storeSecretResolver` into the existing `ensureSourceCaches(config, { force: full, materialize: …, secrets: storeSecretResolver })`. This is the step that actually closes the `sync()` gap. (`indexer.ts` reaches `search-source` via dynamic `import(...)`, confirming it is above the cycle.)
|
|
197
|
+
- `src/commands/sources/source-add.ts:173` and `src/commands/read/knowledge.ts:145`: optionally normalize the inline `resolveSecret: resolveSecretFromStore` to `resolveSecret: storeSecretResolver.resolveSecret` — behavior-identical; leaving them untouched still compiles.
|
|
198
|
+
|
|
199
|
+
### 5.5 How it serves fetchers AND provider `sync()`
|
|
200
|
+
|
|
201
|
+
- **Fetchers:** `x.ts` continues to read `context.resolveSecret` via `resolveXBearerToken` (`x.ts:79-87`) — no change to the fetcher. The value now arrives on the `sync()` path too, so `secrets/x-bearer-token` resolves during bundle update, not just from command-layer URL ingest.
|
|
202
|
+
- **Provider `sync()`:** any provider kind that later needs credentials reads `options.secrets` in its own `sync()` — the seam is on the shared `SourceProvider`/`SyncOptions` contract, so it generalizes beyond `website.ts`'s previously-ad-hoc plumbing.
|
|
203
|
+
|
|
204
|
+
**Graft (b) — anti-regression lint:** add a boundary lint (precedent: `scripts/lint-runtime-boundary.ts`, `scripts/lint-write-source-chokepoint.ts`) asserting that `materialize=true` callers of `ensureSourceCaches` supply a `SecretResolver`, neutralizing P4's one weakness (the optional, non-compiler-enforced threading).
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 6. Migration plan (each step keeps the ratchet green; each is independently provable)
|
|
209
|
+
|
|
210
|
+
1. **Add the leaf abstraction.** Add `SecretResolver` + `SyncOptions` to `src/sources/provider.ts` and widen `sync?()` to `SyncOptions`. Type-only, no behavior change — all four provider kinds' `sync` bodies still typecheck.
|
|
211
|
+
*Proves:* `tsc`/build green; import-cycle ratchet green (no new runtime edge; `provider.ts` remains a sink).
|
|
212
|
+
2. **Add the composition root.** Add `export const storeSecretResolver` to `secret-seam.ts`. Include graft (a): register resolved tokens into `redactSensitiveText`'s `sensitiveValues`.
|
|
213
|
+
*Proves:* unit test that `storeSecretResolver.resolveSecret("secrets/x-bearer-token")` returns the file value for a fixture store and `null` on missing/unreadable; a redaction test asserting the token is scrubbed from a synthesized header-bearing error string.
|
|
214
|
+
3. **Thread the option through `ensureSourceCaches`.** Add `secrets?: SecretResolver` to its options and forward it into `provider.sync(...)` (`search-source.ts:383`). Type-only import of `SecretResolver`.
|
|
215
|
+
*Proves:* ratchet green (no `env-secret-ref` import added to the subgraph); existing `ensureSourceCaches` tests unchanged (option is optional).
|
|
216
|
+
4. **Populate `website.ts` sync().** Add `resolveSecret: options?.secrets?.resolveSecret` to the `ensureWebsiteMirror` call. Type-only import.
|
|
217
|
+
*Proves:* ratchet green; a website-provider `sync()` test with a stub `SecretResolver` asserts the stub is invoked with `secrets/x-bearer-token` (previously never called).
|
|
218
|
+
5. **Wire the entry surface.** Pass `secrets: storeSecretResolver` at `indexer.ts:688-689`.
|
|
219
|
+
*Proves:* integration test — run bundle update / full index on a website source whose X fetcher needs `secrets/x-bearer-token` with `X_BEARER_TOKEN` **unset**; assert the store value is used (previously env-only), AND assert the token never appears in `output()`, in `appendEvent`/`logs.db`, or in any structured surface.
|
|
220
|
+
6. **Normalize command call sites (optional, cosmetic).** Swap `source-add.ts:173` / `knowledge.ts:145` to `storeSecretResolver.resolveSecret`.
|
|
221
|
+
*Proves:* existing command-layer fetcher tests remain green (behavior-identical).
|
|
222
|
+
7. **Add the anti-regression lint (graft b).** Assert `materialize=true` callers of `ensureSourceCaches` pass a `SecretResolver`.
|
|
223
|
+
*Proves:* the lint fails on a deliberately-omitted call site and passes on the wired one.
|
|
224
|
+
|
|
225
|
+
Each step is additive and green in isolation, so the cycle ratchet never goes red and the change can land incrementally.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## 7. Risks and non-goals
|
|
230
|
+
|
|
231
|
+
**Risks**
|
|
232
|
+
|
|
233
|
+
- **Optional-threading regression (accepted, mitigated).** `secrets?` is optional and not compiler-enforced; a future `ensureSourceCaches`/`provider.sync` call site that forgets it silently reverts to env-var-only — the exact failure class being fixed, relocated to "did every caller pass it?". Graft (b)'s lint is the guard.
|
|
234
|
+
- **New value-materialization location.** The bundle-update / `sync()` path now reads a secret into memory where it previously did not. It stays confined to the `fetch()` call frame (`x.ts:111-118`), but it is a new location to audit against the never-in-context bar; graft (a) covers the residual serialized-header case.
|
|
235
|
+
- **Shared-contract widening.** `SourceProvider.sync`'s option type widens for a concern only `website` needs today. This is intentional future-proofing (OCP), and all existing `sync` bodies still typecheck, but it does touch a semi-public interface.
|
|
236
|
+
- **Underlying coupling remains.** P4 does **not** delete the `env-secret-ref → search-source` edge; it only guarantees the store stays on the outer side of it. The architectural smell (the store resolver transitively importing the whole source-resolution machinery) persists — see non-goals.
|
|
237
|
+
|
|
238
|
+
**Non-goals**
|
|
239
|
+
|
|
240
|
+
- **No new command surface / return path.** Do not add an `x-bearer-token path`-style command; the precedent (`secret path` / `secret remove` removal, R-027 / D-49) is that akm removes a surface rather than risk a resolver mismatch or accidental exposure.
|
|
241
|
+
- **Do not rely on `--format` / `output()` exemption as the safety mechanism.** The new path must be structurally value-free on its own (same-frame header set, never returned upward), not depend on CLI-level formatting exemptions.
|
|
242
|
+
- **Do not ship Proposal 3's independent enumerator now.** Deleting the `env-secret-ref → search-source` edge (P3) is the correct *eventual* structural cleanup, but only if `secretSourceRoots` **delegates to a shared lock-first helper** also used by `resolveEntryContentDir`; the as-written second-source-of-truth reintroduces the resolver-mismatch class that already cost `secret path` / `secret remove`. Track it as a separate, later refactor. **Update (issue #744): that condition was tested and cannot be met today — see §8 for the measurements and the single blocking edge.**
|
|
243
|
+
- **Do not adopt Proposal 2's breaking field rename now.** The opaque `CredentialProvider` (void/boolean methods, strongest containment) is the principled long-term target if a breaking contract change ever becomes acceptable; today its lockstep churn is unjustified when P4 reaches the same consumers additively.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 8. P3 feasibility re-assessment (issue #744, 2026-08-12) — BLOCKED on one edge
|
|
248
|
+
|
|
249
|
+
§7 defers P3 with a condition: it may land "only if `secretSourceRoots` **delegates to a shared lock-first helper** also used by `resolveEntryContentDir`." That condition was tested directly. **It cannot be met today.** The blocker is a single import edge, named at the end of this section; P3 should stay deferred until that edge is severed.
|
|
250
|
+
|
|
251
|
+
### 8.1 What was built and measured
|
|
252
|
+
|
|
253
|
+
The strongest possible reading of the §7 condition was implemented as a probe: rather than write a *new* `secretSourceRoots` that "delegates to" a shared helper, `SearchSource`, `resolveSourceEntries` and `resolveEntryContentDir` were moved **verbatim** out of `indexer/search/search-source.ts` into a candidate leaf module, so the reader and the indexer would call the *same function object* — one resolver by construction, not two that agree by convention. That is strictly better than the proposal's sketch and removes any second-source-of-truth argument.
|
|
254
|
+
|
|
255
|
+
It still fails, for a reason the original critique did not reach.
|
|
256
|
+
|
|
257
|
+
### 8.2 Finding 1 — the "lock-first helper" is lock-first but **provider-second**, and the second half is load-bearing
|
|
258
|
+
|
|
259
|
+
`lockContentRootFor` (`src/integrations/lockfile.ts:169-177`) answers **only** for `git`/`npm` bundles that carry a lock `localRoot`; its first line returns `undefined` for every other kind by construction:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
if (!bundleId || (type !== "git" && type !== "npm")) return undefined;
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
So for a **filesystem** bundle (the primary bundle — where `secrets/` actually live), a **website** cache, and a **legacy/unlocked git** bundle, the content root comes exclusively from the second half of `resolveEntryContentDir`: `resolveSourceProviderFactory(entry.type)` → `factory(entry)` → `provider.path()`. Exactly the three cases the issue's acceptance criteria demand be pinned.
|
|
266
|
+
|
|
267
|
+
And that second half is not an implementation detail to route around — it is the repo's *declared* single source of truth (`search-source.ts:161-166`): "each provider owns its own path… This replaces the old per-kind switch ladder (filesystem path / git cache / website cache) that lived here in 0.6.0." Any leaf resolver that derives those paths itself reintroduces the ladder 0.6.0 deleted.
|
|
268
|
+
|
|
269
|
+
### 8.3 Finding 2 — a leaf cannot populate the provider registry, and a half-populated registry mis-resolves **silently**
|
|
270
|
+
|
|
271
|
+
The registry is populated by one eager side-effect import, `search-source.ts:16` (`import "../../sources/providers/index"`). A leaf reader structurally cannot perform it: `providers/index → website.ts → website-ingest.ts → snapshot-fetchers/registry.ts → x.ts` is the fetcher subgraph, and pulling it in is the very coupling P3 exists to delete.
|
|
272
|
+
|
|
273
|
+
Measured against a fixture config holding all five shapes (filesystem primary, lock-managed git, **legacy/unlocked git**, **website cache**, lock-managed npm), same config, two processes:
|
|
274
|
+
|
|
275
|
+
| bundle | via `search-source` (registered) | via the leaf (not registered) |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `primary` (filesystem) | `…/fixture/primary-stash` | `…/.akm/unresolved-sources/primary` ❌ |
|
|
278
|
+
| `locked-git` | `…/installs/locked-git/content` | same ✅ (lock-first) |
|
|
279
|
+
| `legacy-git` | `…/cache/registry-index/git-07aa…/repo` | same ✅ *(by accident — see below)* |
|
|
280
|
+
| `site-cache` (website) | `…/cache/registry-index/website-de10…` | `…/.akm/unresolved-sources/site-cache` ❌ |
|
|
281
|
+
| `locked-npm` | `…/installs/locked-npm` | same ✅ (lock-first) |
|
|
282
|
+
|
|
283
|
+
End to end, with the secret genuinely present in the primary bundle:
|
|
284
|
+
|
|
285
|
+
- `resolveSecretPath("secrets/x-bearer-token")` today → `…/fixture/primary-stash/secrets/x-bearer-token`
|
|
286
|
+
- the same logic on the leaf enumerator → `…/.akm/unresolved-sources/primary/secrets/x-bearer-token`
|
|
287
|
+
|
|
288
|
+
**A different file, no error, no warning** — `findEnvSource` falls back to `candidates[0]` when its `existsSync` probe finds nothing, and `resolveSecretFromStore` swallows every failure to `null`. This is precisely the resolver-mismatch class that cost `secret path` / `secret remove` (R-027 / D-49).
|
|
289
|
+
|
|
290
|
+
Worse than a clean failure: registration is **partial and accidental**. In the leaf's import closure `git` *is* registered — not deliberately, but because `core/write-source.ts` happens to import `sources/providers/git.ts`. So the leaf resolves git correctly and filesystem/website incorrectly, and which kinds work depends on which unrelated modules got pulled in. A resolver whose correctness varies by transitive import order is the worst available property.
|
|
291
|
+
|
|
292
|
+
### 8.4 Finding 3 — all three escapes are closed
|
|
293
|
+
|
|
294
|
+
| Escape | Result |
|
|
295
|
+
|---|---|
|
|
296
|
+
| (a) Derive filesystem/website/git roots in the leaf | Reinstates the per-kind switch ladder deleted in 0.6.0 — the second source of truth §7 forbids. |
|
|
297
|
+
| (b) Side-effect-import `providers/index` from the leaf | Measured: **7 cycle participants** (`env-secret-read`, `source-entries`, `providers/index`, `website`, `website-ingest`, `snapshot-fetchers/registry`, `x`) against an **absolute 0 baseline**. This is the subgraph coming back through another door. |
|
|
298
|
+
| (c) Rely on ambient registration order | §4.1's rejected P1 flaw, with a worse failure mode: P1 degraded to env-var-only, this resolves a *different file*. Making it loud needs either a hardcoded four-kind list (new duplicated truth) or turning `resolveEntryContentDir`'s deliberately-soft `undefined` degradation — which the indexer relies on to keep running past a bad source — into a throw. |
|
|
299
|
+
|
|
300
|
+
### 8.5 The one blocking edge, and the prerequisite
|
|
301
|
+
|
|
302
|
+
The single edge that makes `providers/index` unimportable from a leaf is:
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
src/sources/providers/website.ts ──▶ src/sources/snapshot-fetchers/website-ingest.ts
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`website.ts` needs `getWebsiteCachePaths` / `validateWebsiteUrl` / `shouldAllowPrivateWebsiteUrlForTests` for `path()`, and `ensureWebsiteMirror` for `sync()`. The other three providers are already subgraph-free (`filesystem.ts`, `git.ts`, `npm.ts`).
|
|
309
|
+
|
|
310
|
+
Re-running the P3 graph with **only that edge removed** yields **0 cycle participants**. So the prerequisite is exact and singular:
|
|
311
|
+
|
|
312
|
+
1. Extract the pure path derivations (`getWebsiteCachePaths` + `normalizeSiteUrl`, `validateWebsiteUrl`, `shouldAllowPrivateWebsiteUrlForTests`) from `website-ingest.ts` into a leaf module both it and `website.ts` import. Necessary, **not sufficient** — `sync()` still reaches `ensureWebsiteMirror`.
|
|
313
|
+
2. Stop `website.ts`'s `sync()` from statically importing `ensureWebsiteMirror` — inject the mirror as a capability on `SyncOptions`, bound from a composition root, exactly the P4 idiom applied to the mirror instead of the secret.
|
|
314
|
+
|
|
315
|
+
With both done, `providers/index` becomes a leaf-importable registration point, the moved `resolveSourceEntries` is genuinely one shared resolver, and P3's acceptance criteria are reachable. Until then P3 cannot satisfy §7's condition and must not land.
|
|
316
|
+
|
|
317
|
+
### 8.6 Correction to the issue's framing
|
|
318
|
+
|
|
319
|
+
`CYCLE_PARTICIPANT_BASELINE` is **already empty** and the ratchet is absolute; `bun scripts/lint-import-cycles.ts` reports `0 cycle participant(s)`. There is no import cycle in `src/` today — `env-secret-ref → search-source` is a one-way DAG edge, and the cycle is only *latent* (it closes if a fetcher imports the reader). So #744's "the baseline shrinks" criterion is vacuous as written: the measurable win P3 offers is not a smaller baseline but the removal of the latent back-edge, which is what §8.5 gates.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Runtime Boundary Design — AKM 0.9.0
|
|
2
|
+
|
|
3
|
+
> **Implementation note (added later):** both target files below now exist —
|
|
4
|
+
> `src/storage/database.ts` and `src/runtime.ts` — so this design has landed.
|
|
5
|
+
> The "3 hard-import files" named in the original checklist (`src/indexer/db.ts`,
|
|
6
|
+
> `src/core/state-db.ts`, `src/workflows/db.ts`) no longer describes the current
|
|
7
|
+
> tree: `src/indexer/db.ts` was later split into `src/storage/repositories/index-*.ts`
|
|
8
|
+
> (its `Database` opener lives in `src/storage/repositories/index-connection.ts`,
|
|
9
|
+
> which imports `openDatabase`/`Database` from `src/storage/database.ts` as this
|
|
10
|
+
> design specifies). `src/workflows/db.ts` did exist historically but has since
|
|
11
|
+
> been removed — workflow run persistence now lives in
|
|
12
|
+
> `src/storage/repositories/workflow-runs-repository.ts`.
|
|
13
|
+
> `src/core/state-db.ts` still exists and also imports from `src/storage/database.ts`.
|
|
14
|
+
> The checklist items below are kept as the historical design record.
|
|
15
|
+
|
|
16
|
+
## Problem
|
|
17
|
+
|
|
18
|
+
Runtime-specific dependencies (`bun:sqlite`, `Bun.*` APIs) leak directly into 33+ source files.
|
|
19
|
+
This creates unnecessary coupling, makes Node.js compatibility hard, and means every future
|
|
20
|
+
runtime change has a large blast radius.
|
|
21
|
+
|
|
22
|
+
## Goal
|
|
23
|
+
|
|
24
|
+
Two files own the entire runtime boundary. Everything else is clean application code.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## File 1: `src/storage/database.ts`
|
|
29
|
+
|
|
30
|
+
Single source of truth for SQLite. The rest of the app never imports from `bun:sqlite` or
|
|
31
|
+
`better-sqlite3` directly — only from here.
|
|
32
|
+
|
|
33
|
+
**Responsibilities:**
|
|
34
|
+
- Detect runtime (bun vs node) once at module load
|
|
35
|
+
- Load the appropriate SQLite driver (`bun:sqlite` on Bun, `better-sqlite3` on Node)
|
|
36
|
+
- Export `Database` type — the common structural type both drivers share
|
|
37
|
+
- Export `openDatabase(path, opts?)` factory
|
|
38
|
+
|
|
39
|
+
**Design constraints:**
|
|
40
|
+
- No adapter classes, no interface hierarchies — the driver APIs are already nearly identical
|
|
41
|
+
- The exported `Database` type should be the structural intersection of what both drivers expose
|
|
42
|
+
- `openDatabase()` should be a simple function, not a factory class
|
|
43
|
+
- `db.query()` (Bun-specific) must be normalised — replace with `db.prepare().all()` at this boundary
|
|
44
|
+
|
|
45
|
+
**Architect: investigate and fill in:**
|
|
46
|
+
- [ ] Read `src/indexer/db.ts`, `src/core/state-db.ts`, `src/workflows/db.ts` — the 3 hard-import files
|
|
47
|
+
- [ ] Identify every method AKM actually calls on a `Database` instance (exec, prepare, transaction, close, query, etc.)
|
|
48
|
+
- [ ] Check whether `better-sqlite3`'s type definitions can be used as the exported `Database` type directly, or if a small structural type alias is needed
|
|
49
|
+
- [ ] Check `sqliteVec.load(db)` — confirm whether it accepts both driver handles or needs a raw handle escape hatch
|
|
50
|
+
- [ ] Write the complete file
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## File 2: `src/runtime.ts`
|
|
55
|
+
|
|
56
|
+
~60 lines. Named exports for every `Bun.*` API called outside of the SQLite layer.
|
|
57
|
+
Each export works on both runtimes.
|
|
58
|
+
|
|
59
|
+
**Known Bun.* call sites to cover (architect: verify these are complete):**
|
|
60
|
+
|
|
61
|
+
| Export name | Bun API | Node equivalent |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `spawnSync(cmd, opts)` | `Bun.spawnSync` | `child_process.spawnSync` |
|
|
64
|
+
| `spawn(cmd, opts)` | `Bun.spawn` | `child_process.spawn` |
|
|
65
|
+
| `readStdin(limitBytes)` | `Bun.stdin.stream()` | `for await (chunk of process.stdin)` |
|
|
66
|
+
| `writeResponseToFile(path, res)` | `Bun.write(path, response)` | `stream/promises pipeline` |
|
|
67
|
+
| `sha256Hex(data)` | `new Bun.CryptoHasher('sha256')` | `node:crypto createHash` |
|
|
68
|
+
| `md5Hex(data)` | `new Bun.CryptoHasher('md5')` | `node:crypto createHash` |
|
|
69
|
+
| `semverOrder(a, b)` | `Bun.semver.order` | `semver.compare` (add dep) |
|
|
70
|
+
| `getDirname(importMetaUrl)` | `import.meta.dir` | `path.dirname(new URL(url).pathname)` |
|
|
71
|
+
| `resolveModule(spec, from)` | `Bun.resolveSync` | `require.resolve` (already has fallback) |
|
|
72
|
+
| `sleepSync(ms)` | `Bun.sleepSync` | `Atomics.wait` (already has fallback) |
|
|
73
|
+
| `mainPath` | `Bun.main` | `process.argv[1]` |
|
|
74
|
+
|
|
75
|
+
**Design constraints:**
|
|
76
|
+
- Runtime detection: `const isBun = !!process.versions?.bun` at top of file, used inline
|
|
77
|
+
- No class, no factory — just named function exports
|
|
78
|
+
- Each export branches once on `isBun`, no per-call overhead
|
|
79
|
+
- `node:crypto` is available in both Bun and Node — the crypto exports can skip the `isBun`
|
|
80
|
+
branch entirely and just use `createHash` always (simpler, same perf for a CLI)
|
|
81
|
+
|
|
82
|
+
**Architect: investigate and fill in:**
|
|
83
|
+
- [ ] `grep -rn "Bun\." src --include="*.ts"` — confirm the table above is complete
|
|
84
|
+
- [ ] `grep -rn "import\.meta\.dir" src --include="*.ts"` — list all sites, note which have fallbacks
|
|
85
|
+
- [ ] Confirm `Readable.toWeb()` is the right Node 18 approach for `writeResponseToFile`
|
|
86
|
+
- [ ] Write the complete file
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Call Site Changes
|
|
91
|
+
|
|
92
|
+
Once both files exist, the remaining work is mechanical:
|
|
93
|
+
|
|
94
|
+
**SQLite type-only imports (~30 files):**
|
|
95
|
+
```ts
|
|
96
|
+
// Before
|
|
97
|
+
import type { Database } from 'bun:sqlite'
|
|
98
|
+
// After
|
|
99
|
+
import type { Database } from '../storage/database'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**SQLite hard imports (3 files — `indexer/db.ts`, `core/state-db.ts`, `workflows/db.ts`):**
|
|
103
|
+
- Replace `import { Database } from 'bun:sqlite'` with `import { openDatabase, type Database } from '../storage/database'`
|
|
104
|
+
- Replace `new Database(path)` with `openDatabase(path)`
|
|
105
|
+
- Replace `db.query<T>(sql).all(params)` with `db.prepare(sql).all(params)` (Bun-specific API)
|
|
106
|
+
- Remove `SQLQueryBindings` spread casts
|
|
107
|
+
|
|
108
|
+
**Bun.* call sites (~10 files):**
|
|
109
|
+
```ts
|
|
110
|
+
// Before
|
|
111
|
+
Bun.spawnSync(['git', 'ls-files', ...])
|
|
112
|
+
// After
|
|
113
|
+
import { spawnSync } from '../runtime'
|
|
114
|
+
spawnSync(['git', 'ls-files', ...])
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Architect: investigate and fill in:**
|
|
118
|
+
- [ ] List every call site file and the exact import line change needed
|
|
119
|
+
- [ ] Flag any call site where the shim return shape differs from the Bun API (e.g. stdout as string vs Buffer)
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## `package.json` Changes
|
|
124
|
+
|
|
125
|
+
- Add `better-sqlite3` as `optionalDependency` (prebuilt binaries, no compile step on common platforms)
|
|
126
|
+
- Add `@types/better-sqlite3` to `devDependencies`
|
|
127
|
+
- Add `semver` to `dependencies` (tiny, zero-deps)
|
|
128
|
+
- Add `node: ">=22"` to `engines` (raised from 20.12 when Node 20 support was dropped, 2026-07) (`@clack/core` uses `node:util.styleText`,
|
|
129
|
+
which was added in Node 20.12)
|
|
130
|
+
- Require Node >= 22 in the npm `preinstall` guard
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## What This Is NOT
|
|
135
|
+
|
|
136
|
+
- Not an adapter pattern with interface hierarchies
|
|
137
|
+
- Not a ports-and-adapters architecture
|
|
138
|
+
- Not a plugin system
|
|
139
|
+
- Not a DI container
|
|
140
|
+
|
|
141
|
+
Just two modules that own the runtime details so the rest of the codebase doesn't have to.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Child Issues to Create (after design is complete)
|
|
146
|
+
|
|
147
|
+
1. `src/storage/database.ts` + migrate 3 hard-import files
|
|
148
|
+
2. Mechanical: update 30 type-only imports (sed pass + tsc verify)
|
|
149
|
+
3. `src/runtime.ts` + update ~10 call sites
|
|
150
|
+
4. `package.json` + relax preinstall guard + CI Node matrix
|
|
151
|
+
|
|
152
|
+
Plus: audit open GitHub issues on itlackey/akm and close any that are superseded by this work
|
|
153
|
+
or otherwise stale (milestone 0.9.0 items already addressed, etc.).
|