feat(s3,gcs): namespace manifest support for registerNamespace/listNamespaces (#547) #28

Merged
stack72 merged 2 commits from worktree-547 into main 2026-06-03 23:27:50 +00:00
Owner

Summary

  • Implement registerNamespace() and listNamespaces() on both S3 and GCS DatastoreProvider implementations
  • registerNamespace writes a .namespace.json manifest to {namespace}/.namespace.json in the bucket with check-then-write conflict detection (different repoId → error)
  • listNamespaces scans the bucket for all */.namespace.json keys and returns namespace slugs
  • Methods go directly on the provider (not sync service) — they're datastore-level operations

What changed

File Change
datastore/s3/extensions/datastores/_lib/interfaces.ts Added registerNamespace? and listNamespaces? to DatastoreProvider interface
datastore/gcs/extensions/datastores/_lib/interfaces.ts Same interface additions
datastore/s3/extensions/datastores/s3.ts Implemented both methods on S3DatastoreProviderImpl
datastore/gcs/extensions/datastores/gcs.ts Implemented both methods on GcsDatastoreProviderImpl, imported NotFoundError
datastore/s3/extensions/datastores/s3_test.ts 5 new mock-server tests (register, re-register, conflict, list empty, list populated)
datastore/gcs/extensions/datastores/gcs_test.ts 5 new mock-server tests mirroring S3
datastore/s3/manifest.yaml Version bump 2026.06.03.12026.06.03.2
datastore/gcs/manifest.yaml Version bump 2026.06.03.12026.06.03.2
datastore/s3/README.md Documented namespace manifest support
datastore/gcs/README.md Documented namespace manifest support

Design decisions

  • No retryregisterNamespace is a rare, human-initiated operation. If the PUT fails, the user re-runs the command.
  • TOCTOU acceptable — GET-then-PUT is not fully atomic, but namespace registration is infrequent. Same pattern as .datastore.lock in the lock implementation.
  • Provider-level, not sync-level — matches the core DatastoreProvider interface from Phase 5.
  • Error handling matches each provider — S3 catches error.name === "NotFound" || "NoSuchKey", GCS catches instanceof NotFoundError.

Verification

Unit tests (mock HTTP servers)

  • S3: 152 passed, 0 failed (5 new namespace tests)
  • GCS: 142 passed, 0 failed (5 new namespace tests)

E2E: S3 via MinIO (full CLI path)

  • swamp datastore namespace set infra — manifest written, no warning
  • aws s3 cp s3://manifest-test/infra/.namespace.json - — valid JSON with {namespace, repoId, registeredAt}
  • Second repo namespace set infraError: 'Namespace "infra" is already registered'
  • swamp datastore namespace list — shows both infra and security from bucket
  • swamp datastore status — healthy, all dirs present (solo mode regression pass)

E2E: GCS via real bucket (swamp-uat-gcs-datastore-testing)

  • All 6 scenarios passed (register, list, re-register, conflict, second namespace, list both)

Test plan

  • S3 unit tests pass (mock HTTP server)
  • GCS unit tests pass (mock HTTP server)
  • S3 E2E against MinIO via swamp datastore namespace set/list CLI
  • GCS E2E against real GCS bucket
  • Conflict detection works (different repoId rejected)
  • Re-registration works (same repoId accepted)
  • Solo mode regression (existing operations unchanged)
  • deno check, deno lint, deno fmt --check, deno install --frozen pass for both extensions

Closes #547

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

## Summary - Implement `registerNamespace()` and `listNamespaces()` on both S3 and GCS `DatastoreProvider` implementations - `registerNamespace` writes a `.namespace.json` manifest to `{namespace}/.namespace.json` in the bucket with check-then-write conflict detection (different `repoId` → error) - `listNamespaces` scans the bucket for all `*/.namespace.json` keys and returns namespace slugs - Methods go directly on the provider (not sync service) — they're datastore-level operations ## What changed | File | Change | |------|--------| | `datastore/s3/extensions/datastores/_lib/interfaces.ts` | Added `registerNamespace?` and `listNamespaces?` to `DatastoreProvider` interface | | `datastore/gcs/extensions/datastores/_lib/interfaces.ts` | Same interface additions | | `datastore/s3/extensions/datastores/s3.ts` | Implemented both methods on `S3DatastoreProviderImpl` | | `datastore/gcs/extensions/datastores/gcs.ts` | Implemented both methods on `GcsDatastoreProviderImpl`, imported `NotFoundError` | | `datastore/s3/extensions/datastores/s3_test.ts` | 5 new mock-server tests (register, re-register, conflict, list empty, list populated) | | `datastore/gcs/extensions/datastores/gcs_test.ts` | 5 new mock-server tests mirroring S3 | | `datastore/s3/manifest.yaml` | Version bump `2026.06.03.1` → `2026.06.03.2` | | `datastore/gcs/manifest.yaml` | Version bump `2026.06.03.1` → `2026.06.03.2` | | `datastore/s3/README.md` | Documented namespace manifest support | | `datastore/gcs/README.md` | Documented namespace manifest support | ## Design decisions - **No retry** — `registerNamespace` is a rare, human-initiated operation. If the PUT fails, the user re-runs the command. - **TOCTOU acceptable** — GET-then-PUT is not fully atomic, but namespace registration is infrequent. Same pattern as `.datastore.lock` in the lock implementation. - **Provider-level, not sync-level** — matches the core `DatastoreProvider` interface from Phase 5. - **Error handling matches each provider** — S3 catches `error.name === "NotFound" || "NoSuchKey"`, GCS catches `instanceof NotFoundError`. ## Verification ### Unit tests (mock HTTP servers) - S3: 152 passed, 0 failed (5 new namespace tests) - GCS: 142 passed, 0 failed (5 new namespace tests) ### E2E: S3 via MinIO (full CLI path) - `swamp datastore namespace set infra` — manifest written, no warning - `aws s3 cp s3://manifest-test/infra/.namespace.json -` — valid JSON with `{namespace, repoId, registeredAt}` - Second repo `namespace set infra` — `Error: 'Namespace "infra" is already registered'` - `swamp datastore namespace list` — shows both `infra` and `security` from bucket - `swamp datastore status` — healthy, all dirs present (solo mode regression pass) ### E2E: GCS via real bucket (`swamp-uat-gcs-datastore-testing`) - All 6 scenarios passed (register, list, re-register, conflict, second namespace, list both) ## Test plan - [x] S3 unit tests pass (mock HTTP server) - [x] GCS unit tests pass (mock HTTP server) - [x] S3 E2E against MinIO via `swamp datastore namespace set/list` CLI - [x] GCS E2E against real GCS bucket - [x] Conflict detection works (different repoId rejected) - [x] Re-registration works (same repoId accepted) - [x] Solo mode regression (existing operations unchanged) - [x] `deno check`, `deno lint`, `deno fmt --check`, `deno install --frozen` pass for both extensions Closes #547 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat(s3,gcs): add registerNamespace and listNamespaces to datastore providers (#547)
Some checks failed
CI / workflows/gcs-bootstrap - fmt (pull_request) Has been skipped
CI / cloudflare models - sample check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - fmt (pull_request) Has been skipped
CI / vault/1password - test (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lint (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - test (pull_request) Has been skipped
CI / workflows/s3-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - check (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - lint (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / model/hetzner-cloud - check (pull_request) Has been skipped
CI / model/digitalocean - lockfile up to date (pull_request) Has been skipped
CI / model/hetzner-cloud - lockfile up to date (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / model/digitalocean - check (pull_request) Has been skipped
CI / Claude Code Review (pull_request) Successful in 3m34s
CI / Adversarial Code Review (pull_request) Failing after 4m8s
CI / Merge Gate (pull_request) Failing after 25s
db547b099f
Implement namespace manifest support for multi-repo conflict detection
on S3 and GCS datastore providers. registerNamespace writes a
.namespace.json manifest to {namespace}/.namespace.json in the bucket
with check-then-write conflict detection. listNamespaces scans the
bucket for all registered namespace manifests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. TOCTOU race in registerNamespace (both providers) — The check-then-write pattern (getObject → inspect repoId → putObject) is not atomic. Two concurrent registerNamespace calls for the same namespace with different repoIds can both pass the check before either write lands, causing the later write to silently overwrite the first. The README documents this honestly as "check-then-write conflict detection," so it appears intentional, but it's worth noting that GCS already exposes putObjectConditional (used by the lock path for ifGenerationMatch=0 CAS). Using that for a write-if-not-exists on first registration would close the window without extra round-trips. S3 similarly supports If-None-Match: *. Low urgency for an advisory detection mechanism, but worth a follow-up.

    • datastore/gcs/extensions/datastores/gcs.ts:165–191
    • datastore/s3/extensions/datastores/s3.ts:159–193
  2. S3 registerNamespace: redundant catch branch — The else if (error instanceof Error && error.message.startsWith("Namespace")) branch re-throws, and the trailing else also re-throws. Both arms do identical work, so the middle branch is dead code. Collapsing to a single else { throw error } would make the intent clearer.

    • datastore/s3/extensions/datastores/s3.ts:172–185
  3. listNamespaces issues a full-bucket scan — Both implementations call listAllObjects() with no key-prefix hint and then filter by suffix in application code. For a bucket with many objects (large deployment, many model files), this can be expensive. If the client API accepts a prefix argument, passing something like .namespace.json would narrow the list. Alternatively, writing namespace manifests into a dedicated _namespaces/ directory would allow a prefix-scoped list.

    • datastore/gcs/extensions/datastores/gcs.ts:194–208
    • datastore/s3/extensions/datastores/s3.ts:195–209
  4. Prefix handling not verifiable from changed files — Both registerNamespace and listNamespaces construct keys as ${namespace}/.namespace.json and call listAllObjects() using a client built from the full config (which may include a user-supplied prefix). If GcsClient/S3Client do not apply this.config.prefix to these calls, then two installations sharing a bucket under different prefixes (e.g., prod vs staging) would see each other's namespace registrations. Worth a quick cross-check against the client implementations to confirm the prefix is applied consistently to namespace-path keys.

## Code Review ### Blocking Issues None. ### Suggestions 1. **TOCTOU race in `registerNamespace` (both providers)** — The check-then-write pattern (`getObject` → inspect repoId → `putObject`) is not atomic. Two concurrent `registerNamespace` calls for the same namespace with different `repoId`s can both pass the check before either write lands, causing the later write to silently overwrite the first. The README documents this honestly as "check-then-write conflict detection," so it appears intentional, but it's worth noting that GCS already exposes `putObjectConditional` (used by the lock path for `ifGenerationMatch=0` CAS). Using that for a write-if-not-exists on first registration would close the window without extra round-trips. S3 similarly supports `If-None-Match: *`. Low urgency for an advisory detection mechanism, but worth a follow-up. - `datastore/gcs/extensions/datastores/gcs.ts:165–191` - `datastore/s3/extensions/datastores/s3.ts:159–193` 2. **S3 `registerNamespace`: redundant catch branch** — The `else if (error instanceof Error && error.message.startsWith("Namespace"))` branch re-throws, and the trailing `else` also re-throws. Both arms do identical work, so the middle branch is dead code. Collapsing to a single `else { throw error }` would make the intent clearer. - `datastore/s3/extensions/datastores/s3.ts:172–185` 3. **`listNamespaces` issues a full-bucket scan** — Both implementations call `listAllObjects()` with no key-prefix hint and then filter by suffix in application code. For a bucket with many objects (large deployment, many model files), this can be expensive. If the client API accepts a prefix argument, passing something like `.namespace.json` would narrow the list. Alternatively, writing namespace manifests into a dedicated `_namespaces/` directory would allow a prefix-scoped list. - `datastore/gcs/extensions/datastores/gcs.ts:194–208` - `datastore/s3/extensions/datastores/s3.ts:195–209` 4. **Prefix handling not verifiable from changed files** — Both `registerNamespace` and `listNamespaces` construct keys as `${namespace}/.namespace.json` and call `listAllObjects()` using a client built from the full config (which may include a user-supplied `prefix`). If `GcsClient`/`S3Client` do _not_ apply `this.config.prefix` to these calls, then two installations sharing a bucket under different prefixes (e.g., `prod` vs `staging`) would see each other's namespace registrations. Worth a quick cross-check against the client implementations to confirm the prefix is applied consistently to namespace-path keys.
Author
Owner

Adversarial Review

Critical / High

  1. TOCTOU race condition in registerNamespace — conflict detection is defeated by concurrent callers — datastore/s3/extensions/datastores/s3.ts:159-192 and datastore/gcs/extensions/datastores/gcs.ts:165-191

    Both implementations use a non-atomic check-then-write pattern: GET the manifest, inspect repoId, then unconditionally PUT. Between the GET and PUT, another process can complete its own GET+PUT cycle, and the second writer silently overwrites the first without detecting the conflict.

    Breaking scenario:

    • Process A (repo-aaa) calls registerNamespace("infra", "repo-aaa")
    • Process B (repo-bbb) calls registerNamespace("infra", "repo-bbb")
    • A does GET infra/.namespace.json -> NotFoundError
    • B does GET infra/.namespace.json -> NotFoundError
    • A does PUT with repoId repo-aaa — succeeds
    • B does PUT with repoId repo-bbb — silently overwrites A manifest

    Both repos now believe they own the infra namespace. The README and interface doc explicitly describe this as conflict detection, so users will rely on it to prevent two repos from stomping on each other.

    Suggested fix: Both clients already expose putObjectConditional — GCS uses ifGenerationMatch=0 and S3 uses IfNoneMatch: *. The first-time registration should use putObjectConditional instead of plain putObject. If it returns false/null (object already exists), then GET the existing manifest and check repoId. This makes first-writer-wins atomic. The re-registration (same repoId) path can still use plain putObject.

    Severity: HIGH — This defeats the stated purpose of the feature (multi-repo conflict detection). Two repos racing to register the same namespace will silently corrupt state.

Medium

  1. listNamespaces downloads the entire object listing to find namespace manifests — datastore/s3/extensions/datastores/s3.ts:195-209 and datastore/gcs/extensions/datastores/gcs.ts:194-208

    listAllObjects() is called with no sub-prefix, which pages through every object under the configured bucket prefix. In a production datastore with thousands or millions of objects, this will be very slow and memory-intensive just to find the handful of .namespace.json files.

    S3/GCS only support prefix-based filtering (not suffix), so the current key scheme does not allow efficient listing. A common pattern is to store manifests under a well-known prefix like .namespaces/slug.json, which enables a targeted listing that returns only manifest entries.

    Not a correctness bug, but will degrade as datastores grow.

  2. S3 registerNamespace error matching is fragile for corrupted manifest JSON — datastore/s3/extensions/datastores/s3.ts:172-185

    If the stored .namespace.json contains syntactically valid JSON with an unexpected shape (e.g. repoId is null from a partial write or manual edit), then existing.repoId !== repoId evaluates to null !== "repo-aaa" which is true, and the error message becomes: Namespace "infra" is already registered by repo null. This is technically correct (it blocks the registration) but could be confusing to operators and masks a data corruption issue. Consider validating the parsed shape before comparing.

Low

  1. Re-registration unconditionally overwrites the manifest — datastore/s3/extensions/datastores/s3.ts:187-192 and datastore/gcs/extensions/datastores/gcs.ts:186-191

    When existing.repoId equals the provided repoId (idempotent re-registration), the code falls through to putObject and writes a new manifest with a fresh registeredAt timestamp. Every call writes to the bucket even when nothing changed. Not a bug — likely intentional idempotency — but an unnecessary write that could be avoided with an early return when the existing manifest already matches.

Verdict

FAIL — The TOCTOU race in registerNamespace (finding 1) defeats the stated purpose of multi-repo conflict detection. Two concurrent registrations for the same namespace with different repoIds can silently overwrite each other. Both providers already have putObjectConditional available to make first-writer-wins atomic. This should be fixed before merge.

## Adversarial Review ### Critical / High 1. **TOCTOU race condition in registerNamespace — conflict detection is defeated by concurrent callers** — datastore/s3/extensions/datastores/s3.ts:159-192 and datastore/gcs/extensions/datastores/gcs.ts:165-191 Both implementations use a non-atomic check-then-write pattern: GET the manifest, inspect repoId, then unconditionally PUT. Between the GET and PUT, another process can complete its own GET+PUT cycle, and the second writer silently overwrites the first without detecting the conflict. Breaking scenario: - Process A (repo-aaa) calls registerNamespace("infra", "repo-aaa") - Process B (repo-bbb) calls registerNamespace("infra", "repo-bbb") - A does GET infra/.namespace.json -> NotFoundError - B does GET infra/.namespace.json -> NotFoundError - A does PUT with repoId repo-aaa — succeeds - B does PUT with repoId repo-bbb — silently overwrites A manifest Both repos now believe they own the infra namespace. The README and interface doc explicitly describe this as conflict detection, so users will rely on it to prevent two repos from stomping on each other. Suggested fix: Both clients already expose putObjectConditional — GCS uses ifGenerationMatch=0 and S3 uses IfNoneMatch: *. The first-time registration should use putObjectConditional instead of plain putObject. If it returns false/null (object already exists), then GET the existing manifest and check repoId. This makes first-writer-wins atomic. The re-registration (same repoId) path can still use plain putObject. Severity: HIGH — This defeats the stated purpose of the feature (multi-repo conflict detection). Two repos racing to register the same namespace will silently corrupt state. ### Medium 1. **listNamespaces downloads the entire object listing to find namespace manifests** — datastore/s3/extensions/datastores/s3.ts:195-209 and datastore/gcs/extensions/datastores/gcs.ts:194-208 listAllObjects() is called with no sub-prefix, which pages through every object under the configured bucket prefix. In a production datastore with thousands or millions of objects, this will be very slow and memory-intensive just to find the handful of .namespace.json files. S3/GCS only support prefix-based filtering (not suffix), so the current key scheme does not allow efficient listing. A common pattern is to store manifests under a well-known prefix like .namespaces/slug.json, which enables a targeted listing that returns only manifest entries. Not a correctness bug, but will degrade as datastores grow. 2. **S3 registerNamespace error matching is fragile for corrupted manifest JSON** — datastore/s3/extensions/datastores/s3.ts:172-185 If the stored .namespace.json contains syntactically valid JSON with an unexpected shape (e.g. repoId is null from a partial write or manual edit), then existing.repoId !== repoId evaluates to null !== "repo-aaa" which is true, and the error message becomes: Namespace "infra" is already registered by repo null. This is technically correct (it blocks the registration) but could be confusing to operators and masks a data corruption issue. Consider validating the parsed shape before comparing. ### Low 1. **Re-registration unconditionally overwrites the manifest** — datastore/s3/extensions/datastores/s3.ts:187-192 and datastore/gcs/extensions/datastores/gcs.ts:186-191 When existing.repoId equals the provided repoId (idempotent re-registration), the code falls through to putObject and writes a new manifest with a fresh registeredAt timestamp. Every call writes to the bucket even when nothing changed. Not a bug — likely intentional idempotency — but an unnecessary write that could be avoided with an early return when the existing manifest already matches. ### Verdict **FAIL** — The TOCTOU race in registerNamespace (finding 1) defeats the stated purpose of multi-repo conflict detection. Two concurrent registrations for the same namespace with different repoIds can silently overwrite each other. Both providers already have putObjectConditional available to make first-writer-wins atomic. This should be fixed before merge.
fix: use putObjectConditional for atomic namespace registration
All checks were successful
CI / workflows/gcs-bootstrap - fmt (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - fmt (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lint (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - test (pull_request) Has been skipped
CI / workflows/s3-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/dirtyfrag - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / model/digitalocean - check (pull_request) Has been skipped
CI / model/hetzner-cloud - lockfile up to date (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / codegen - fmt (pull_request) Has been skipped
CI / codegen - lint (pull_request) Has been skipped
CI / codegen - lockfile up to date (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / Claude Code Review (pull_request) Successful in 4m11s
CI / Adversarial Code Review (pull_request) Successful in 4m11s
CI / Merge Gate (pull_request) Successful in 37s
1ca1458e1f
Replace the non-atomic GET-then-PUT pattern with putObjectConditional
(S3: If-None-Match: *, GCS: ifGenerationMatch=0) for first-writer-wins
atomicity. If the conditional write fails (object exists), GET and check
repoId for conflict detection. Closes the TOCTOU race where concurrent
callers could silently overwrite each other's manifests.

Also update copyright headers to Elder Swamp Club, Inc. and remove
unused NotFoundError import from GCS provider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Code Review

This PR adds registerNamespace / listNamespaces to both the GCS and S3 datastore providers, replacing a non-atomic GET-then-PUT with putObjectConditional for first-writer-wins atomicity. The core mechanism is correct: conditional writes prevent TOCTOU races on initial registration, the conflict-detection read-back is well-structured, and tests cover the full lifecycle (create, idempotent re-register, conflict) using local mock servers with no live cloud dependencies.

Blocking Issues

None.

Suggestions

  1. Inconsistent putObjectConditional return-value checks (gcs.ts:173 vs s3.ts:167)
    GCS uses if (created !== null) return; while S3 uses if (created) return;. If both underlying clients return null on a 412, the S3 check is also correct (null is falsy), but the divergence makes the invariant implicit. A short comment naming the expected return type contract for each client would prevent a future maintainer from accidentally inverting the logic.

  2. Unsafe JSON.parse cast in registerNamespace (gcs.ts:178, s3.ts:172)

    const existing = JSON.parse(text) as { namespace: string; repoId: string; ... };
    

    If the stored manifest is malformed or written by an old client that omitted repoId, existing.repoId is undefined and the error message reads "already registered by repo undefined". Adding a simple presence check (if (typeof existing?.repoId !== "string")) before the comparison would give a clearer diagnostic.

  3. Namespace value used raw in object key (gcs.ts:163, s3.ts:157)

    const key = `${namespace}/.namespace.json`;
    

    If namespace ever contains a path-separator sequence like ../ the object key will be syntactically valid in both GCS and S3 (object stores treat keys as opaque strings, so this isn't a filesystem path-traversal risk) but it could silently produce keys outside the expected namespace layout. A guard rejecting slashes or .. components would make the contract explicit and protect future callers.

  4. GCS README documents defaultRequestTimeoutMs but schema doesn't include it (gcs/README.md, gcs.ts:72–106)
    The README's configuration table lists defaultRequestTimeoutMs as a supported user-facing option, but gcsConfigSchema and GcsDatastoreProviderConfig have no such field. Zod's default strip behaviour means any value a user configures will be silently discarded before it reaches GcsClient. If the field is intentionally handled at a lower layer (e.g. read directly from env or passed differently), a brief README note to that effect would clarify; if it's unimplemented, the table row should be removed or marked as coming in a future release.

  5. Copyright header inconsistency in interfaces.ts files
    _lib/interfaces.ts (both GCS and S3) still carries Copyright (C) 2026 System Initiative, Inc. while the other changed files were updated to Elder Swamp Club, Inc. per the commit message. Not a functional issue, but the commit message says headers were updated so the omission looks unintentional.

## Code Review This PR adds `registerNamespace` / `listNamespaces` to both the GCS and S3 datastore providers, replacing a non-atomic GET-then-PUT with `putObjectConditional` for first-writer-wins atomicity. The core mechanism is correct: conditional writes prevent TOCTOU races on initial registration, the conflict-detection read-back is well-structured, and tests cover the full lifecycle (create, idempotent re-register, conflict) using local mock servers with no live cloud dependencies. ### Blocking Issues None. ### Suggestions 1. **Inconsistent `putObjectConditional` return-value checks (gcs.ts:173 vs s3.ts:167)** GCS uses `if (created !== null) return;` while S3 uses `if (created) return;`. If both underlying clients return `null` on a 412, the S3 check is also correct (null is falsy), but the divergence makes the invariant implicit. A short comment naming the expected return type contract for each client would prevent a future maintainer from accidentally inverting the logic. 2. **Unsafe `JSON.parse` cast in `registerNamespace` (gcs.ts:178, s3.ts:172)** ```typescript const existing = JSON.parse(text) as { namespace: string; repoId: string; ... }; ``` If the stored manifest is malformed or written by an old client that omitted `repoId`, `existing.repoId` is `undefined` and the error message reads "already registered by repo undefined". Adding a simple presence check (`if (typeof existing?.repoId !== "string")`) before the comparison would give a clearer diagnostic. 3. **Namespace value used raw in object key (gcs.ts:163, s3.ts:157)** ```typescript const key = `${namespace}/.namespace.json`; ``` If `namespace` ever contains a path-separator sequence like `../` the object key will be syntactically valid in both GCS and S3 (object stores treat keys as opaque strings, so this isn't a filesystem path-traversal risk) but it could silently produce keys outside the expected namespace layout. A guard rejecting slashes or `..` components would make the contract explicit and protect future callers. 4. **GCS README documents `defaultRequestTimeoutMs` but schema doesn't include it (gcs/README.md, gcs.ts:72–106)** The README's configuration table lists `defaultRequestTimeoutMs` as a supported user-facing option, but `gcsConfigSchema` and `GcsDatastoreProviderConfig` have no such field. Zod's default strip behaviour means any value a user configures will be silently discarded before it reaches `GcsClient`. If the field is intentionally handled at a lower layer (e.g. read directly from env or passed differently), a brief README note to that effect would clarify; if it's unimplemented, the table row should be removed or marked as coming in a future release. 5. **Copyright header inconsistency in interfaces.ts files** `_lib/interfaces.ts` (both GCS and S3) still carries `Copyright (C) 2026 System Initiative, Inc.` while the other changed files were updated to `Elder Swamp Club, Inc.` per the commit message. Not a functional issue, but the commit message says headers were updated so the omission looks unintentional.
Author
Owner

Adversarial Review

Medium

  1. TOCTOU race in registerNamespace — unconditional overwrite on re-registration

    • Files: datastore/gcs/extensions/datastores/gcs.ts:189, datastore/s3/extensions/datastores/s3.ts:183
    • What's wrong: When a process re-registers the same namespace (same repoId), the code reads the existing manifest (verifying repoId matches), then performs an unconditional putObject to update the timestamp. Between the read and the write, a third party could delete the manifest and a different process could successfully create it with a different repoId via putObjectConditional. The unconditional putObject then silently overwrites the new registration.
    • Breaking scenario: Process A (repoId: "aaa") re-registers namespace "infra". After A's getObject confirms repoId matches, an operator deletes infra/.namespace.json (cleanup, lifecycle rule, etc.). Process B (repoId: "bbb") calls registerNamespace — its putObjectConditional succeeds (object was deleted). Then A's unconditional putObject fires and overwrites B's legitimate claim with A's manifest. No error is raised.
    • Suggested fix: Either (a) skip the timestamp-update write entirely since the existing manifest is already correct, or (b) use a conditional write (GCS putObjectConditionalGeneration / S3 etag-based conditional) for the update so it fails if the object changed between read and write.
  2. listNamespaces performs a full-prefix scan of all objects

    • Files: datastore/gcs/extensions/datastores/gcs.ts:192-206, datastore/s3/extensions/datastores/s3.ts:186-200
    • What's wrong: listAllObjects() with no subPrefix lists every object under the configured bucket prefix, then filters client-side for keys ending in /.namespace.json. For a datastore with thousands of data files, metadata files, and index entries, this downloads a full object listing just to find a handful of namespace manifests.
    • Breaking scenario: A bucket with 100k objects under the prefix. Each S3 ListObjectsV2 page returns 1000 keys, so this issues 100 LIST API calls just to find 3 namespace manifests. At scale, this is slow and expensive (S3 charges per LIST request).
    • Suggested fix: Use delimiter-based listing (delimiter "/") to enumerate top-level prefixes, then issue targeted headObject or getObject calls for {prefix}/.namespace.json. This reduces API calls from O(total objects / 1000) to O(namespaces).

Low

  1. No input validation on namespace parameter in registerNamespace

    • Files: datastore/gcs/extensions/datastores/gcs.ts:159, datastore/s3/extensions/datastores/s3.ts:153
    • What's wrong: The namespace string is interpolated directly into the object key as ${namespace}/.namespace.json. An empty string produces the key /.namespace.json (leading slash). A namespace containing / (e.g., "a/b") creates a/b/.namespace.json, which nests deeper than expected and won't be discovered by listNamespaces if it only expects single-segment prefixes.
    • Practical impact: Object storage treats keys as opaque strings so there's no path traversal risk, but malformed namespaces could create undiscoverable manifests. The listNamespaces implementation does handle multi-segment namespace slugs correctly (slices everything before /.namespace.json), so this is cosmetic rather than a correctness bug.
    • Suggested fix: Validate that namespace is non-empty and contains no / characters before constructing the key, or document that multi-segment namespaces are intentionally supported.
  2. registerNamespace will throw an opaque error if the object is deleted between conditional-write failure and subsequent read

    • Files: datastore/gcs/extensions/datastores/gcs.ts:175-178, datastore/s3/extensions/datastores/s3.ts:169-172
    • What's wrong: If putObjectConditional indicates the object already exists (returns null/false), but the object is deleted before getObject runs, the caller gets a raw "Not Found" / "NoSuchKey" error with no indication that this was a namespace registration conflict check. The retry-safe behavior is fine, but the error message is confusing.
    • Practical impact: Unlikely in normal operation; a retry would succeed. Cosmetic issue.

Verdict

PASS — The core logic is correct: atomic first-writer-wins via conditional puts, proper idempotent re-registration, and correct handling of the different return types between S3 (boolean) and GCS (GcsWriteResult | null). The TOCTOU in finding #1 requires external deletion during a narrow window and is a theoretical concern rather than a practical production risk. The full-scan in listNamespaces is a scalability concern worth addressing before the namespace count or bucket size grows, but it's functionally correct. Tests are thorough and follow project conventions (local mock servers, sanitizeResources: false with explanatory comments, finally cleanup, conformance helpers).

## Adversarial Review ### Medium 1. **TOCTOU race in `registerNamespace` — unconditional overwrite on re-registration** - **Files**: `datastore/gcs/extensions/datastores/gcs.ts:189`, `datastore/s3/extensions/datastores/s3.ts:183` - **What's wrong**: When a process re-registers the same namespace (same `repoId`), the code reads the existing manifest (verifying `repoId` matches), then performs an **unconditional** `putObject` to update the timestamp. Between the read and the write, a third party could delete the manifest and a different process could successfully create it with a different `repoId` via `putObjectConditional`. The unconditional `putObject` then silently overwrites the new registration. - **Breaking scenario**: Process A (`repoId: "aaa"`) re-registers namespace `"infra"`. After A's `getObject` confirms repoId matches, an operator deletes `infra/.namespace.json` (cleanup, lifecycle rule, etc.). Process B (`repoId: "bbb"`) calls `registerNamespace` — its `putObjectConditional` succeeds (object was deleted). Then A's unconditional `putObject` fires and overwrites B's legitimate claim with A's manifest. No error is raised. - **Suggested fix**: Either (a) skip the timestamp-update write entirely since the existing manifest is already correct, or (b) use a conditional write (GCS `putObjectConditionalGeneration` / S3 etag-based conditional) for the update so it fails if the object changed between read and write. 2. **`listNamespaces` performs a full-prefix scan of all objects** - **Files**: `datastore/gcs/extensions/datastores/gcs.ts:192-206`, `datastore/s3/extensions/datastores/s3.ts:186-200` - **What's wrong**: `listAllObjects()` with no subPrefix lists every object under the configured bucket prefix, then filters client-side for keys ending in `/.namespace.json`. For a datastore with thousands of data files, metadata files, and index entries, this downloads a full object listing just to find a handful of namespace manifests. - **Breaking scenario**: A bucket with 100k objects under the prefix. Each S3 `ListObjectsV2` page returns 1000 keys, so this issues 100 LIST API calls just to find 3 namespace manifests. At scale, this is slow and expensive (S3 charges per LIST request). - **Suggested fix**: Use delimiter-based listing (delimiter `"/"`) to enumerate top-level prefixes, then issue targeted `headObject` or `getObject` calls for `{prefix}/.namespace.json`. This reduces API calls from O(total objects / 1000) to O(namespaces). ### Low 3. **No input validation on `namespace` parameter in `registerNamespace`** - **Files**: `datastore/gcs/extensions/datastores/gcs.ts:159`, `datastore/s3/extensions/datastores/s3.ts:153` - **What's wrong**: The `namespace` string is interpolated directly into the object key as `${namespace}/.namespace.json`. An empty string produces the key `/.namespace.json` (leading slash). A namespace containing `/` (e.g., `"a/b"`) creates `a/b/.namespace.json`, which nests deeper than expected and won't be discovered by `listNamespaces` if it only expects single-segment prefixes. - **Practical impact**: Object storage treats keys as opaque strings so there's no path traversal risk, but malformed namespaces could create undiscoverable manifests. The `listNamespaces` implementation does handle multi-segment namespace slugs correctly (slices everything before `/.namespace.json`), so this is cosmetic rather than a correctness bug. - **Suggested fix**: Validate that `namespace` is non-empty and contains no `/` characters before constructing the key, or document that multi-segment namespaces are intentionally supported. 4. **`registerNamespace` will throw an opaque error if the object is deleted between conditional-write failure and subsequent read** - **Files**: `datastore/gcs/extensions/datastores/gcs.ts:175-178`, `datastore/s3/extensions/datastores/s3.ts:169-172` - **What's wrong**: If `putObjectConditional` indicates the object already exists (returns `null`/`false`), but the object is deleted before `getObject` runs, the caller gets a raw "Not Found" / "NoSuchKey" error with no indication that this was a namespace registration conflict check. The retry-safe behavior is fine, but the error message is confusing. - **Practical impact**: Unlikely in normal operation; a retry would succeed. Cosmetic issue. ### Verdict **PASS** — The core logic is correct: atomic first-writer-wins via conditional puts, proper idempotent re-registration, and correct handling of the different return types between S3 (`boolean`) and GCS (`GcsWriteResult | null`). The TOCTOU in finding #1 requires external deletion during a narrow window and is a theoretical concern rather than a practical production risk. The full-scan in `listNamespaces` is a scalability concern worth addressing before the namespace count or bucket size grows, but it's functionally correct. Tests are thorough and follow project conventions (local mock servers, `sanitizeResources: false` with explanatory comments, `finally` cleanup, conformance helpers).
stack72 deleted branch worktree-547 2026-06-03 23:27:51 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
swamp-club/swamp-extensions!28
No description provided.