Skip to content

chore(ci)(deps): bump actions/upload-artifact from 4 to 7#7

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/github_actions/actions/upload-artifact-7
Closed

chore(ci)(deps): bump actions/upload-artifact from 4 to 7#7
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/github_actions/actions/upload-artifact-7

Conversation

@dependabot
Copy link
Copy Markdown

@dependabot dependabot Bot commented on behalf of github May 19, 2026

Bumps actions/upload-artifact from 4 to 7.

Release notes

Sourced from actions/upload-artifact's releases.

v7.0.0

v7 What's new

Direct Uploads

Adds support for uploading single files directly (unzipped). Callers can set the new archive parameter to false to skip zipping the file during upload. Right now, we only support single files. The action will fail if the glob passed resolves to multiple files. The name parameter is also ignored with this setting. Instead, the name of the artifact will be the name of the uploaded file.

ESM

To support new versions of the @actions/* packages, we've upgraded the package to ESM.

What's Changed

New Contributors

Full Changelog: actions/upload-artifact@v6...v7.0.0

v6.0.0

v6 - What's new

[!IMPORTANT] actions/upload-artifact@v6 now runs on Node.js 24 (runs.using: node24) and requires a minimum Actions Runner version of 2.327.1. If you are using self-hosted runners, ensure they are updated before upgrading.

Node.js 24

This release updates the runtime to Node.js 24. v5 had preliminary support for Node.js 24, however this action was by default still running on Node.js 20. Now this action by default will run on Node.js 24.

What's Changed

Full Changelog: actions/upload-artifact@v5.0.0...v6.0.0

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

... (truncated)

Commits
  • 043fb46 Merge pull request #797 from actions/yacaovsnc/update-dependency
  • 634250c Include changes in typespec/ts-http-runtime 0.3.5
  • e454baa Readme: bump all the example versions to v7 (#796)
  • 74fad66 Update the readme with direct upload details (#795)
  • bbbca2d Support direct file uploads (#764)
  • 589182c Upgrade the module to ESM and bump dependencies (#762)
  • 47309c9 Merge pull request #754 from actions/Link-/add-proxy-integration-tests
  • 02a8460 Add proxy integration test
  • b7c566a Merge pull request #745 from actions/upload-artifact-v6-release
  • e516bc8 docs: correct description of Node.js 24 support in README
  • Additional commits viewable in compare view

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code labels May 19, 2026
@julia-kafarska julia-kafarska deleted the dependabot/github_actions/actions/upload-artifact-7 branch May 19, 2026 21:47
@dependabot @github
Copy link
Copy Markdown
Author

dependabot Bot commented on behalf of github May 19, 2026

OK, I won't notify you again about this release, but will get in touch when a new version is available. If you'd rather skip all updates until the next major or minor version, let me know by commenting @dependabot ignore this major version or @dependabot ignore this minor version. You can also ignore all major, minor, or patch releases for a dependency by adding an ignore condition with the desired update_types to your config file.

If you change your mind, just re-open this PR and I'll resolve any conflicts on it.

julia-kafarska added a commit that referenced this pull request May 20, 2026
Six useEffect blocks (install-inspector + update-inspector + auto-organize + logCanvasRender + overlay-dismiss-reset + overlay-dismiss-on-AI) consolidate into packages/ui/src/features/canvas/hooks/use-canvas-side-effects.ts. The autoOrganizeCard threshold > 10 (blueprint risk #7) preserved. The setOverlayDismissed read-but-never-read-back setter (blueprint risk #8) preserved verbatim.

No behavior change.
julia-kafarska added a commit that referenced this pull request May 20, 2026
Sixth Layer 1 leaf-component extraction. Replaces the inline IIFE at
deploy-panel.tsx L577–672 with `<DnsRecordsSection results={deploy.results} />`.
The component owns the empty-state early-return, so the IIFE wrap-in-`(() => {})()`
collapses to a clean component call. Inner `renderRecord` and `renderHeader`
helpers move to file-private functions in the new module.

RISK #7 from the rf-pdpl blueprint preserved: `(r.outputs as any).custom_domain_dns_records`
and `(r.outputs as any)?.custom_domain` casts kept verbatim — switching to a
type guard would silently drop malformed records the original code rendered.

The orchestrator's `extractDnsResults`, `splitDnsByAction`, and `DnsRec` imports
are removed (no longer used after the IIFE collapses).

34 tests, 100/90/100/100 coverage on the new module. The 90% branch coverage
reflects the structurally-unreachable `|| []` defensive fallback on the
`custom_domain_dns_records` cast — `extractDnsResults` already filters to
results whose array is truthy + non-empty, so the branch never fires post-filter.
julia-kafarska added a commit that referenced this pull request May 20, 2026
…ser.ts

Combined the two units atomically. Splitting would require a placeholder
stub for parse_primary; the circular import between the two files
resolves at function-call time so they must land together.

- parser-binary-exprs.ts (321 LOC): 10-level expression grammar chain
  parse_expression → parse_conditional → parse_or → parse_and →
  parse_equality → parse_comparison → parse_term → parse_factor →
  parse_unary → parse_postfix.
- parser-primary.ts (354 LOC): parse_primary, parse_array_expression,
  parse_object_expression, parse_for_expression, parse_reference.
- parser.ts: 938 → 505 LOC. 15 expression methods deleted; the 7
  this.parse_expression() callsites in block-parsers rewritten to
  parse_expression(this.state).

Pinned 6 risks via 94 new tests (838 → 932 in @ice/core, all pass):
RISK #5 (parse_equality operator ternary), #6 (parse_postfix error-but-
continue), #7 (10-level precedence chain order), #8 (parse_primary
pre-advance snapshot), #9 (parse_for_expression key/value identity),
#10 (parse_reference path undefined vs []).
julia-kafarska added a commit that referenced this pull request May 20, 2026
…0 follow-up)

Bug. `MutableGraph._nodes` is `Map<NodeId, Node>` keyed by branded
`${type}:${name}` NodeIds. Pass 1.4 / 1.45 / 1.5 were calling
`graph.nodes.get(name as any)` against a Map keyed by NodeIds with bare
resource names from `card_id_to_name`, which always missed in
production. The `as any` cast hid the type mismatch from the compiler.
Net effect: in production, the repository wiring (Pass 1.4), custom
domain propagation (Pass 1.45), and endpoint backend lookups (Pass 1.5)
were silent no-ops on every iteration through the
`if (!targetGraphNode) continue` guard.

Fix. Replaced the four `graph.nodes.get(name as any)` callsites with
`graph.get_node_by_name(name)`, which accepts a bare string and resolves
via the internal `node_names: Map<string, NodeId>`:

  - packages/core/src/deploy/passes/pass-1-4-repo-wiring.ts:56
  - packages/core/src/deploy/passes/pass-1-45-domain-propagation.ts:69
  - packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts:228
  - packages/core/src/deploy/passes/pass-1-5-endpoint-wiring.ts:304

A fifth dependent site — `graph.remove_node(forwardingResourceName
as any)` at pass-1-5-endpoint-wiring.ts:283 — also needed migrating:
`remove_node` accepts only `NodeId`, so we resolve the bare name via
`get_node_by_name(name)?.id` first. Without this the RISK #7 atomic
forwarding-rule removal would silently no-op under the new bare-name
flow.

Test fixture migration. The existing test fixtures across all three
pass test files were written with the bug-bypass shape:
`card_id_to_name.set(cardId, branded NodeId)` so the buggy
`graph.nodes.get(name as any)` hit by accident. Migrated all 71 existing
tests to the production shape: `card_id_to_name.set(cardId, bareName)`.
Cascading consequence: the cert name and backend service name
derivations (`sanitize_name(`${name}-cert`)` / `${name}-backend`) now
produce clean `fr-1-cert` / `svc-1-backend` instead of the
NodeId-derived `gcp-compute-globalforwardingrule-fr-1-cert` form. Test
assertions updated accordingly.

Regression coverage. Added 4 new tests (one per affected file +
one extra static-site test in pass-1-5) that explicitly use the
production-shape mapping and assert the mutation path actually fires.
All 679 deploy tests pass (75 in passes/__tests__, up from 71).

Behavior change: this is a bug fix, not a refactor. Pre-fix, the three
passes were silent no-ops in production for any deploy that relied on
Source.Repository wiring, Network.CustomDomain propagation, or
Network.PublicEndpoint backend wiring.
julia-kafarska added a commit that referenced this pull request May 20, 2026
Highest-risk unit. Pull scan_heredoc out of the Lexer class into a
sibling lexer-heredoc.ts. Imports is_alpha and is_digit from
lexer-scanners.ts (rf-lex-2). Mechanical port — no logic changes.

Pins four RISKs from the blueprint:

  RISK #7 — Terminator backtrack to check_start (NOT line_start).
            The leading indent whitespace consumed for indentation
            is permanently consumed on the failing-match path. The
            backtrack contract is about cursor position management
            (so the trailing read-until-newline doesn't double-
            consume the indent), NOT about content offsets — the
            content slice already covers the line from line_start.

  RISK #8 — content_end = line_start (start of terminator line)
            BEFORE leading-whitespace consumption. The terminating
            content.trimEnd() strips the trailing newline + indent
            in one shot. Reordering breaks both indented and
            non-indented heredocs.

  RISK #9 — EOF without a closing delimiter is SILENT — no
            ls_add_error fires. The token still emits with an
            empty literal (content_end stays at content_start
            initial value). Preserve verbatim — downstream
            consumers may rely on this shape.

  RISK #10 — Two newline-accounting sites: opening-line and
             content-loop. Both ls_advance + line++ + column=1.
             Both load-bearing.

Tests: 244 → 257 (+13). Includes a direct unit test that seeds
LexerState with cursor at the post-dispatch position, plus
integration tests through Lexer.tokenize. All four RISK pins have
dedicated assertions.
julia-kafarska added a commit that referenced this pull request May 20, 2026
Verified:
  - No circular runtime imports. Module graph:
      lexer.ts → tokens, lexer-state, lexer-scanners, lexer-heredoc
      lexer-state.ts → tokens; type-only from lexer (mirrors
        parser-state's type-only edge to parser — proven safe)
      lexer-scanners.ts → tokens, lexer-state
      lexer-heredoc.ts → lexer-state, lexer-scanners
  - index.ts re-exports unchanged (no diff vs main).
  - pnpm --filter @ice/core typecheck has no NEW errors in lexer/
    parser paths (pre-existing TS2834 errors in src/index.ts and
    schema/embedded-schema-provider.ts are baseline; unchanged).
  - 257 tests passing in packages/core/src/graph/parser; 1071 core
    tests overall.

State files:
  - refactor-targets.md: lexer.ts marked DONE in queue; removed
    from Phase 2 priority list.
  - learnings.md: appended heredoc-content-end-includes-line-leading-
    whitespace gotcha (test-design trap I hit on RISK #7 — the
    backtrack-to-check_start contract is about cursor management,
    NOT content slice trimming).
  - state/blueprints/rf-lex.md: blueprint preserved for traceability.
julia-kafarska added a commit that referenced this pull request May 20, 2026
Single implementation of the public-grant strategy used by both
create() and update(). The shared module (~190 LOC) replaces the
inline blocks at create L192-325 and update L509-678 (~310 inline
LOC combined) with a 6-line orchestrator call.

verifyAfterWrite asymmetry preserved per RISK #7:
  create  -> verifyAfterWrite: false  (write-and-go)
  update  -> verifyAfterWrite: true   (re-fetch, detect silent strip)

The unified version uses the richer update() flow as the baseline:
  - alreadyHasAllUsers fast-path skips redundant setPolicy
  - existing-binding member append (no duplicate role rows)
  - access-prevention scan covers all 5 strings
  - logs harmonized to update()'s 'on ${name}' format

25 new tests cover:

  RISK #4 (merge not replace) — etag echo, version echo, version
    default to 3, version preserved when policy returns it, no
    duplicate objectViewer row, existing bindings survive

  RISK #5 (UBLA-forced + IAM-blocked dual block short-circuits ACL)
    — does NOT call acl.default.add when ublaForcedOn=true and IAM
    rejects; sets failed=true with the dual-block warning

  RISK #6 (ACL dual calls) — both acl.default.add and acl.add
    invoked; bucket-level acl.add rejection swallowed

  RISK #7 (verifyAfterWrite asymmetry) — verify path detects silent
    strip (re-fetch returns policy without allUsers); skip-verify
    path makes only one getPolicy call

  Org-policy detection — 'permitted customer', 'allowedPolicyMember
    Domains', 'stripped' (silent-strip surface in verify mode)

  Access-prevention detection — 'publicAccessPrevention', 'PUBLIC_
    ACCESS_PREVENTION', 'uniform bucket-level access', 'UBLA',
    'blocked'

Orchestrator drops to 464 LOC (-208 LOC vs previous unit).
julia-kafarska added a commit that referenced this pull request May 20, 2026
Closes pdl-5 critic finding #7 (deferred to follow-up). When a tab
joins mid-deploy, Phase 2 of useDeploySubscription pulls the persisted
snapshot from the gateway and was previously only mirroring the
overlay onto the canvas blocks. The deploy panel's per-row list,
which derives from `nodesById`, stayed empty until Phase 2.5's replay
loop completed — leaving a brief "Preparing…" sentinel window even
though the per-block overlays already showed the right colors.

The warm-seed dispatches synthetic `node_status` events (and
`node_progress` when the snapshot carries a step descriptor) for each
node in `snapshot.nodeStatuses`, so the panel's per-row list renders
immediately.

Synthetic events use seq=0 so any live event (or replayed event from
Phase 2.5) with seq>0 dedup-wins on the same node, overwriting the
warm-seed entry's empty resource_name / resource_type / action with
the authoritative wire values.

New helper: overlayToWireStatus inverts mapWireStatusToOverlay
(snapshot stores the post-mapping overlay string; warm-seed walks it
back to a wire DeployNodeStatus). Returns null for overlay strings
that don't round-trip — e.g. 'destroying' / 'gone' from pre-pdl-10
destroy paths — so warm-seed skips them and Phase 2.5 fills them in
from the event tape.

Tests: 14 → 17 in use-deploy-subscription.test.ts (+3 for
overlayToWireStatus: full inverse map, null fallback for unknown
strings, round-trip through mapWireStatusToOverlay).
julia-kafarska added a commit that referenced this pull request May 20, 2026
Findings #7: getOrgId fell back to req.body.organisationId when the
JWT carried no org id, intended as a UX shortcut for stale tokens
after an org switch. The side effect: any authenticated caller
could scope `/projects` (list/create) to ANY org id by passing it
in the body, returning that org's projects.

The hardened path is JWT-only. A stale JWT now yields an empty org
string; downstream service calls return an empty list instead of
reading another org's data, and the user is expected to refresh
the JWT (re-login or call /refresh) after switching orgs.

Inverted the regression test that pinned the body-fallback
behavior; 28 tests pass.
julia-kafarska added a commit that referenced this pull request May 20, 2026
* rf-esp-3: extract graph-query operations from embedded-schema-provider

Pulls get_dependencies, get_dependents, and get_equivalents into
schema/embedded/graph-queries.ts. Each helper takes the registry as its
first arg; behaviour preserved: null registry -> InternalError, max_depth
forwarded to registry, results mapped through convert_resource_to_schema.

The default `max_depth = 10` stays on the orchestrator class so the
public API still exposes the default. 7 tests pin the new helpers.

embedded-schema-provider.ts: 335 -> 324 LOC.

* rf-esp-4: extract events + initialization from embedded-schema-provider

Pulls the on/off/emit_event trio into schema/embedded/events.ts (operating
on a passed-in EventListenerMap) and the dynamic schemas/db import +
project-vs-bundled DB resolution into schema/embedded/initialization.ts.

The dynamic-import path moves from `'../schemas/db'` to
`'../../schemas/db'` since the new file is one directory deeper. The
TS2834 baseline noise simply moves with the import — total count
unchanged at 29 (verified post-extraction).

initialize_registry returns null gracefully when the schemas package
or its `get_schema_registry` export is absent. resolve_db_path tests
chdir into a tmpdir rather than mocking fs (`fs.existsSync` is
non-configurable under Vitest ESM). 14 new tests for the helpers.

embedded-schema-provider.ts: 324 -> 284 LOC. File 1 series complete.

* rf-rval-1: extract type-checker and validation types from resource-validator

Moves the public Validation* types into resource-validator-types.ts so
helper modules can import them without dragging in the orchestrator
class. The shim file `resource-validator.ts` re-exports every type from
the new sibling file -> public API unchanged.

Pulls `validate_type` and `get_type_name` (private methods of
ResourceValidator) into validation/type-checker.ts as standalone pure
functions. The 'object'/'map' shared check, NaN-as-number-mismatch, and
'any' fall-through behaviour are preserved verbatim. 26 tests cover
every branch including unknown-type fall-through.

resource-validator.ts: 543 -> 374 LOC.

* rf-rval-2: extract constraint validation from resource-validator

Pulls `validate_constraints` (private method on ResourceValidator) into
schema/validation/constraints.ts with five named helpers:
  check_enum, check_pattern, check_numeric_range, check_string_length,
  check_array_length, plus a `validate_constraints` orchestrator.

Behaviour preserved verbatim: invalid regex strings silently swallowed,
inclusive boundaries on min/max and min_length/max_length, canonical
issue order (enum, pattern, range, length). Each helper independently
testable. 25 new tests pin every branch including issue ordering.

resource-validator.ts: 374 -> 258 LOC.

* rf-rval-3: extract property-validator and error-conversion

Pulls the recursive `validate_property` walker into
schema/validation/property-validator.ts and the
`to_validation_error` mapper into schema/validation/error-conversion.ts.

The class methods on ResourceValidator now delegate, including the
public `to_validation_error`, `is_valid`, and `validate_property_value`.
The recursive walker is byte-identical (required-missing, type-mismatch,
constraint, and nested object/array branches preserved); 17 new tests
pin its behaviour, plus 5 for the error converter.

resource-validator.ts: 258 -> 157 LOC. File 2 series complete.

* rf-cload-1: extract example-file content + paths from customization-loader

Pulls the four `_example.<ext>.disabled` content blocks (provider JSON,
override YAML, custom resource YAML, relationships YAML) out of the
inline `create_example_files` private method into named constants in
schema/customization/example-files.ts. The CustomizationPaths type and
subdir constants move to schema/customization/paths.ts.

Customization-loader.ts re-exports CustomizationPaths from the new
location so external consumers' import paths are unchanged. Behaviour
preserved: each file only created when missing, content byte-identical.
9 new tests pin both the constants and the directory write-out.

customization-loader.ts: 521 -> 410 LOC.

* rf-cload-2: extract per-file validators from customization-loader

Pulls the four `validate_*_file` private methods (provider JSON, override
YAML, custom resource YAML, relationships YAML) into
schema/customization/file-validators.ts as standalone async functions,
each returning `FileValidationResult`. The CustomizationError and
ValidationWarning types move to that file too — the orchestrator
re-exports them so external consumers (schema/index.ts) keep their
import paths.

Behaviour preserved verbatim: "Invalid JSON: ..." / "Invalid YAML: ..."
prefixes on parse failures, 1-indexed relationship error messages,
provider resources without properties emit warnings (not errors). 16
new tests drive each helper end-to-end via tmp file writes.

customization-loader.ts: 410 -> 261 LOC.

* rf-cload-3: extract directory scanner + base-db resolver

Pulls the `scan_directory` private method into
schema/customization/scanner.ts (along with the CustomizationFile type
that the scanner produces) and the `get_base_db_path` standalone
function into schema/customization/base-db.ts. The orchestrator
re-exports CustomizationFile + delegates `get_base_db_path` so external
imports are unaffected.

Behaviour preserved verbatim including the pre-existing buggy
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` eagerness
(noted in the test file's docstring; not in scope to fix here). 7 new
tests for the scanner driving real fs reads via tmp dirs.

customization-loader.ts: 261 -> 204 LOC. File 3 series complete.

* state: add three learnings from P3 cohort 3 (rf-esp / rf-rval / rf-cload)

- ts2834-baseline-error-moves-with-the-import: extracting a TS2834
  baseline import into a deeper subdirectory relocates the error,
  doesn't introduce one.
- fs-existssync-is-non-configurable-under-vitest-esm: vi.spyOn fails
  on node:fs namespace imports; use chdir into tmpdir instead.
- one-source-of-truth-for-types-in-shim-refactors: when decomposing
  a class file, public types live in a sibling <name>-types.ts to
  avoid cycles between the shim and helper modules.

* rf-pimp-1: extract state parsing helpers from pulumi state-importer

Pulls six pure helpers out of state-importer.ts into a new
parsing.ts module:

  - get_deployment           — read PulumiDeployment from either state shape
  - get_stack_info           — derive {stack,project} from checkpoint or URN
  - extract_name_from_urn    — last-segment fallback when parse_urn fails
  - is_secret_value          — sentinel-UUID check for Pulumi secret wrappers
  - unwrap_secret            — ciphertext > plaintext > value resolver
  - create_empty_metadata    — unknown-sentinel PulumiImportMetadata for errors

state-importer.ts: 564 -> 481 LOC.

22 new tests in parsing.test.ts pin the byte-identical behaviour
(secret sentinel UUID, both state shapes, URN fallback path,
empty-metadata defaults). All 79 existing pulumi/terraform tests
still pass; TS2834 baseline (29) unchanged.

* rf-pimp-2: extract resource conversion from pulumi state-importer

Pulls the resource-shape conversion out of state-importer.ts into a
new resource-conversion.ts module:

  - process_properties — recursive property walker with secret unwrap
  - import_resource    — PulumiResource -> PulumiImportedResource

Both are pure (warnings array is mutated like before).  Behaviour
preserved verbatim:
  - URN parse + extract_name_from_urn fallback for name
  - outputs > inputs precedence (with NO_OUTPUTS warning fallback)
  - dependencies = explicit ++ parent (parent appended last, in order)
  - additional_secret_outputs mirrored verbatim
  - protect/external default false; id passes through

state-importer.ts: 481 -> 387 LOC.

18 new tests pin the secret-mask path, the warning emission path, the
parent-last dependency append, and the protect/external defaults.
86 total importer tests pass; TS2834 baseline (29) unchanged.

* rf-pimp-3: extract graph conversion from pulumi state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph — PulumiImportResult -> MutableGraph
  - import_pulumi_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer and
the existing 79 importer tests bind directly to it.

state-importer.ts: 387 -> 278 LOC (564 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/stack/project)
  - per-node labels (provider, pulumi_type, optional protected/external)
  - per-node provenance annotations (imported_from, pulumi_urn)
  - resource.id lifted to node properties.id
  - self-dependency and missing-target edges skipped
  - target_graph merge mode preserves nodes only (edges already dropped)

12 new tests cover the node properties/labels/annotations, the edge
filters (self-loop + missing target), and the graph metadata.  98
total importer tests pass; TS2834 baseline (29) unchanged.

* rf-timp-1: extract sensitive-attribute masking + empty metadata

Pulls three pure helpers out of state-importer.ts into a new
sensitive.ts module:

  - mask_sensitive_attributes — top-level walker over sensitive paths
  - mask_path                 — recursive leaf-mask mutator
  - create_empty_metadata     — unknown-sentinel ImportMetadata for errors

state-importer.ts: 547 -> 514 LOC.

15 new tests pin the path-tokenisation behaviour:
  - top-level / dotted / bracket-array path forms
  - non-object intermediates abort the walk (no error)
  - missing leaves and null intermediates are no-ops
  - empty path / empty-input early return

All 79 existing pulumi/terraform tests still pass; TS2834 baseline
(29) unchanged.

* rf-timp-2: extract resource conversion + dependency inference

Pulls three functions out of state-importer.ts into a new
resource-conversion.ts module:

  - import_resource_instance — (resource, instance) -> ImportedResource
  - infer_dependencies       — id/arn-driven dep inference post-pass
  - scan_for_references      — recursive walker over property tree

state-importer.ts: 496 -> 368 LOC.

Behaviour preserved verbatim:
  - address = [module.]type.name[index_key], JSON-encoded index_key
  - ICE name = name_prefix + name + (_index_key when present)
  - sensitive_attributes path masking + SENSITIVE_MASKED warning
  - explicit instance.dependencies pass-through
  - id_lookup indexed by both 'id' AND 'arn' property values
  - Set-seeded dedup union of explicit + inferred deps
  - dependencies[] mutated in place per resource

22 new tests cover address building (incl. module/index_key paths),
the masking warning emission, the include_sensitive=true bypass, and
all three sub-cases of scan_for_references (string/array/object/null).
65 total terraform tests pass; TS2834 baseline (29) unchanged.

* rf-timp-3: extract graph conversion from terraform state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph    — TerraformImportResult -> MutableGraph
  - import_terraform_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer.

state-importer.ts: 368 -> 268 LOC (547 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/lineage)
  - per-node labels (provider, terraform_type, optional module)
  - per-node provenance annotations (imported_from, terraform_address)
  - all edges tagged 'inferred: true' regardless of origin
  - missing-target edges silently skipped
  - target_graph merge mode preserves nodes only

10 new tests cover the node properties/labels/annotations, the
optional module label, and the missing-target edge filter. 75 total
terraform importer tests pass; TS2834 baseline (29) unchanged.

* rf-aimp-1: extract ARN/tag helpers from aws-importer

Pulls four pure helpers out of aws-importer.ts into a new
arn-helpers.ts module:

  - extract_name_from_arn   — trailing /-or-:-separated name
  - extract_account_from_arn — 5th segment, '' when malformed
  - extract_region_from_arn  — 4th segment, 'global' default for IAM/CF
  - parse_tags               — Tags-array OR tags-object normalisation

aws-importer.ts: 533 -> 495 LOC.

Behaviour preserved verbatim:
  - 6-segment ARN check, segment-5+ join for resource portion
  - resource fallback when split-on-/-or-: leaves empty trailing
  - global default for empty region slot (IAM, CloudFront)
  - Tags array preferred over tags object when both exist
  - String() coercion for non-string Key/Value pairs

22 new tests cover all four helpers across well-formed ARNs,
malformed inputs, IAM-style global resources, and both tag formats.
TS2834 baseline (29) unchanged.

* rf-aimp-2: extract AWS SDK init from aws-importer

Pulls the dynamic-import wrapper functions out of aws-importer.ts into
a new sdk-init.ts module:

  - AWSSdk interface  — STS / ResourceExplorer / ConfigService bundle
  - init_aws_sdk      — dynamic-import client-sts/-resource-explorer-2/
                        -config-service, optional fromIni({profile})
  - get_account_id    — STS GetCallerIdentity wrapper, 'unknown' on err

aws-importer.ts: 495 -> 444 LOC.

Behaviour preserved verbatim — including the load-bearing
'Function("m", "return import(m)")' pattern that prevents bundlers
from transpiling the dynamic import to a static require (which would
break the optional-dep guarantee for users who never use AWS).

5 new tests cover the friendly install-the-sdk error path and the
'unknown' fallback in get_account_id.  TS2834 baseline (29) unchanged.

* rf-aimp-3: extract resource discovery from aws-importer

Pulls the two paginated AWS-API discovery loops out of aws-importer.ts
into a new discovery.ts module:

  - map_resource_explorer_hit       — pure: hit -> AWSResource
  - map_config_result               — pure: JSON-string -> AWSResource|null
  - discover_with_resource_explorer — paginated SearchCommand wrapper
  - discover_with_config            — paginated SelectResourceConfig wrapper

Two pure mappers were extracted alongside the discover_*() loops to
make the response-shape -> AWSResource conversion testable without
needing to stub the dynamic @aws-sdk/client-* imports (the
'Function("m", "return import(m)")' indirection bypasses any
Vitest module registry).

aws-importer.ts: 438 -> 358 LOC.

Behaviour preserved verbatim:
  - Resource Explorer: QueryString='*', MaxResults=100, NextToken pagination
  - Config: SelectResourceConfigCommand SQL DSL (LIKE '%')
  - region default 'global' for both paths
  - resourceId preferred over ARN-derived name (Config only)
  - JSON.parse failures silently skipped (Config only)

8 new tests cover the pure mappers across well-formed / partial /
malformed inputs, plus the SDK-not-installed failure path on each
discover_*() entrypoint.  TS2834 baseline (29) unchanged.

* rf-aimp-4: extract graph conversion + relationship inference

Pulls the two graph functions out of aws-importer.ts into a new
graph-conversion.ts module:

  - aws_result_to_graph  — AWSImportResult -> MutableGraph
  - infer_relationships  — ARN-driven dep inference post-pass

aws-importer.ts re-exports aws_result_to_graph via barrel-style
'export {...} from' to preserve the public surface — index.ts and
the importers index both bind to it.  Local consumer
import_aws_to_graph aliases the local import as aws_result_to_graph_impl
to avoid same-name collision with the re-export.

aws-importer.ts: 346 -> 250 LOC (533 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source, account_id)
  - per-node labels: provider, aws_type, account_id, region, ...tags
    (tags spread last — tags WIN on key collision with canonical labels)
  - per-node provenance: imported_from, aws_arn, aws_account
  - depends_on edges with inferred:true + source:aws labels
  - self-dependency and missing-target edges silently skipped
  - infer_relationships REPLACES dependencies (not unions) — load-bearing
  - ARN matching gated by 'arn:aws:' prefix and arn_set membership

19 new tests cover the canonical-label-vs-tag collision, the
self-dep/missing-target edge filters, dedup of repeated references,
own-ARN exclusion, and the dependency-replacement (not -union)
contract.  148 total importer tests pass; TS2834 baseline (29)
unchanged.

* state: add two learnings from P3 cohort 4 (rf-aimp series)

dynamic-import-indirection-blocks-test-mocks
  The Function('m', 'return import(m)') pattern in aws-importer
  bypasses Vitest's module registry — vi.mock can't stub the AWS
  SDK calls. Workaround: extract pure mappers and test those.

same-name-local-import-and-reexport-collision
  When an extracted function is both consumed locally AND re-exported
  for the public surface, alias the local import to make the two
  roles explicit (X_impl vs X).

* rf-tfexp-1: extract terraform types module

Verbatim port of TerraformExportOptions / RequiredProvider /
TerraformResource / TerraformLifecycle / TerraformConfig /
TerraformBlock / TerraformProviderConfig / TerraformVariable /
TerraformOutput / TerraformExportResult shapes from
terraform-exporter.ts (pre-extraction L20-160) into a dedicated
types module. Public surface preserved — re-exported from the
orchestrator in the slim-down unit.

* rf-tfexp-2: extract terraform sanitize_name helper

Verbatim port of the private sanitizeName method from
terraform-exporter.ts (pre-extraction L420-428) into a pure
helper module. The Terraform identifier rules differ from
Pulumi's (underscore prefix vs r- prefix; preserves _ in
identifiers); kept separate to avoid coupling.

Tests: 10 cases covering alphanumeric pass-through, dot/slash/
space substitution, leading-digit prefix, unicode replacement.

* rf-tfexp-3: extract terraform fallback_type_mapping helper

Verbatim port of the private fallbackTypeMapping method from
terraform-exporter.ts (pre-extraction L335-362). Provider-prefix
table preserved exactly; gcp/aws/azure branch ordering preserved
(a gcp.* type always hits the gcp branch even if provider token
is non-gcp). Documented the pre-extraction quirks where the aws
and azure branches hard-code the prefix regardless of provider
token.

Tests: 17 cases covering each branch + provider mapping table.

* rf-tfexp-4: extract terraform value-transform helpers

Verbatim port of mapProperties / transformValue / formatDependencies
from terraform-exporter.ts (pre-extraction L367-418). Three pure
transformations with no class-state dependency.

Key differences vs Pulumi's value-transform: keys are preserved
AS-IS (Terraform uses snake_case natively); transform_value does
not rename nested keys. format_dependencies emits # placeholders
(pre-extraction had a TODO comment about lookup; preserved).

Tests: 23 cases covering null/undefined normalisation, _-prefix
filtering at top level only, recursive nested transforms, array
handling, dependency placeholder formatting.

* rf-tfexp-5: extract terraform HCL formatter helpers

Verbatim port of formatHCLValue / toHCL / toJSON from
terraform-exporter.ts (pre-extraction L433-545). Pure functions;
no class state.

The HCL output format is byte-identical to the pre-extraction
class methods. Particularly load-bearing:
 - String escape order (backslash first, then quote)
 - null/undefined property values SKIPPED (not emitted as null)
 - depends_on block omitted when empty
 - HCL object syntax 'key = value' (not JSON-style 'key: value')
 - Trailing blank line after each section

Tests: 37 cases covering all value types + full to_hcl snapshot
regression guard.

* rf-tfexp-6: extract terraform converter helpers

Verbatim port of buildDependencyMap / nodeToResource /
exportGraph from terraform-exporter.ts (pre-extraction L189-330).
The class state previously held by the orchestrator
(schema_provider) is now passed as the first argument to each
helper.

Pre-extraction quirks preserved:
 - depends_on edge filter (other relationships ignored)
 - node.properties || {} defensive default
 - unmapped_types deduped via Set; warnings NOT deduped
 - format-selection branch checks 'json' literal; everything
   else (including undefined) emits HCL

* rf-tfexp-7: slim terraform-exporter orchestrator (558 -> 102 LOC)

The class is now a thin orchestration shell:
 - constructor instantiates schema_provider
 - initialize() lazy-initialises the schema provider
 - exportGraph() delegates to ./terraform/converter.export_graph

Public API unchanged — TerraformExporter, create_terraform_exporter,
and the eleven exported types all keep their pre-extraction shape.
External consumers (export/index.ts) continue importing through
the orchestrator path; the new terraform/* modules are internal.

* rf-pmap-1: extract pulumi type-mapper data tables

Verbatim port of PROVIDER_MAP (~24 entries) and TYPE_MAP
(~280 entries) from importers/pulumi/type-mapper.ts
(pre-extraction L94-413). The TYPE_MAP entries are the
SOURCE OF TRUTH for ICE iceType names — external consumers
depend on the exact dotted-form values; preserved verbatim.

Size exception: 376 LOC justified by data-only nature
(cf. /docs/refactoring-patterns.md 'Data-heavy shim split').

Tests: 18 cases pinning provider count, key existence, ICE
type format, dedup behaviour for collapsed mappings (e.g.
aws:s3/bucket:Bucket and aws:s3/bucketV2:BucketV2 both
mapping to aws.s3.bucket).

* rf-pmap-2: extract pulumi type-mapper URN/type parsers

Verbatim port of parse_urn and parse_type from
importers/pulumi/type-mapper.ts (pre-extraction L19-86).
Pure string parsers; no data-table dependency.

Tests: 19 cases covering URN parts validation, special
type handling (pulumi:pulumi:Stack, pulumi:providers:*),
standard format vs alternative format priority order,
malformed input fallback to empty object.

* rf-pmap-3: extract pulumi type-mapper mapping helpers

Verbatim port of get_ice_type, get_ice_provider,
get_provider_from_type, is_type_supported,
get_supported_types, get_supported_ice_types,
get_name_from_urn, is_provider_resource, is_stack_resource
from importers/pulumi/type-mapper.ts (pre-extraction
L422-527). Plus the private to_snake_case helper
(pre-extraction L500-505).

Pre-extraction quirks preserved:
 - get_ice_type three-stage fallback (TYPE_MAP -> synth -> lowercase dotted)
 - get_ice_provider three-stage fallback (URN -> type -> simple-name -> 'unknown')
 - is_type_supported strict TYPE_MAP membership (synthesised paths excluded)
 - get_supported_ice_types deduped via Set
 - to_snake_case strips leading underscore from ([A-Z]) capture

Tests: 21 cases covering all eight helpers + fallback paths.

* rf-pmap-4: slim pulumi type-mapper orchestrator (527 -> 42 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the (implicit) data-table re-exports keep
their pre-extraction shapes. External consumers
(state-importer.ts, parsing.ts, resource-conversion.ts,
index.ts) continue importing through this shim path
without changes.

* rf-galg-1: extract graph topological-sort + cycle detection

Verbatim port of topological_sort, reverse_topological_sort,
has_cycle, find_cycles, find_cycle_in_subgraph from
graph/algorithms.ts (pre-extraction L18-220). Grouped together
because topological_sort uses find_cycle_in_subgraph (private)
for error reporting; has_cycle is a one-line wrapper around
topological_sort.

Pre-extraction quirks documented:
 - topological_sort double-counts in-degree decrement (iterates
   outgoing edges + ALL edges with target === current)
 - find_cycles returns DUPLICATE cycles when nodes participate
   in multiple cycles (no dedup)
 - find_cycle_in_subgraph returns first cycle OR up to 5 nodes
   from input on failure (best-effort error fallback)

Tests: 16 cases covering linear chains, cycles, disconnected
nodes, non-depends-on edge filtering. Plus shared fixtures.ts
with make_graph helper for the rest of the rf-galg series.

* rf-galg-2: extract graph path-finding helpers

Verbatim port of find_all_paths and find_shortest_path from
graph/algorithms.ts (pre-extraction L229-297). Independent
DFS/BFS path finding; no dependency on topo/cycle/components.

Pre-extraction quirks documented:
 - find_all_paths uses recursive DFS with visited-set cycle
   avoidance + max_paths cap (default 100)
 - find_shortest_path uses Array.shift BFS (O(n) per shift,
   not O(1)); preserved verbatim
 - find_shortest_path returns null on no path, [start] when
   start === end

Tests: 12 cases covering direct edges, diamonds, cycle
avoidance, max_paths cap, null vs empty array.

* rf-galg-3: extract connected components helpers

Verbatim port of find_connected_components and
find_strongly_connected_components from graph/algorithms.ts
(pre-extraction L307-402). Two distinct algorithms:
 - find_connected_components: BFS, treats edges as UNDIRECTED
   (uses both incoming and outgoing edges)
 - find_strongly_connected_components: Tarjan's, treats edges
   as DIRECTED; filters single-node SCCs (length > 1 only)

Pre-extraction quirks documented:
 - find_connected_components tolerates duplicate enqueue (the
   visited.has check is at top of inner loop, not at enqueue)
 - find_strongly_connected_components excludes singleton SCCs
   (intentional — singleton means trivially-strongly-connected,
   so excluding gives meaningful SCCs only)
 - Recursive Tarjan can hit JS stack limits on deep graphs

Tests: 12 cases covering empty graphs, disconnected nodes,
diamonds, mutual cycles, multi-cycle graphs.

* rf-galg-4: extract dependency analysis + graph metrics

Verbatim port of get_execution_layers, get_critical_path,
calculate_metrics, GraphMetrics interface from
graph/algorithms.ts (pre-extraction L412-586). Depends on
helpers from topo-cycle and components modules.

Pre-extraction quirks documented:
 - get_execution_layers uses iterative layer-peel; on cycle
   produces empty layer and breaks (silent ceasing)
 - get_critical_path KNOWN BUG: distance update walks
   incoming-edge predecessors, but topo order processes leaves
   first for depends_on graphs, so the source-distance lookup
   reads -Infinity and the chain never propagates. Effectively
   returns just the start (no-deps) node. Preserved verbatim
   — fixing this changes public behaviour, out-of-scope for
   refactor.
 - calculate_metrics density formula: e/(n*(n-1)); guards
   max_edges > 0 to avoid div-by-zero on empty graphs

Tests: 15 cases covering empty graphs, parallelisable layers,
cycle detection, density edge cases, degree statistics. Two
critical-path tests assert length >= 1 (preserving the
pre-extraction quirk).

* rf-galg-5: slim graph algorithms orchestrator (586 -> 51 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the GraphMetrics type keep their pre-extraction
shapes. External consumers (graph/index.ts,
plan/plan-engine.ts, graph/validator/validators.ts) continue
importing through this shim path without changes.

* rf-pmap rf-galg: append learnings on critical-path quirk + data-table LOC exception

* rf-cmove: split use-container-move.ts (564 → 189 LOC orchestrator)

Decomposes the rf-canv-25b useContainerMove hook into pure runners
under hooks/container-move/. Behavior preserved verbatim — both
handleNodeMove and handleToggleFold delegate to the new sub-modules.

  - container-move/types.ts (25) — PositionUpdate / SizeUpdate
  - container-move/ancestor-expansion.ts (275) — walkAncestorsAndExpand,
    expandAncestorOnce (shared by both handlers, parameterized over
    sibling-bounds reading strategy)
  - container-move/clamp.ts (114) — clampDraggedNodeToParent +
    detectExitingGroupId (tri-state setter directive)
  - container-move/move-runner.ts (127) — runNodeMove pure runner
  - container-move/toggle-fold-runner.ts (187) — resolveToggleFoldDecision
    + runUnfoldExpansion pair

Tests: 27 baseline (use-container-move.test.tsx) + 68 new sub-module
tests = 95 total passing. Coverage on container-move/: 100% statements
/ 100% branches / 100% functions / 100% lines (types.ts excluded as
runtime-empty).

Per blueprint risk #2 — DO NOT consolidate the four ancestor-expansion
sites with rf-canv-4's expandToFitChildren; they have subtly different
rules. The split preserves the per-handler bbox-reading strategy via
the siblingPosLookup + siblingBoundsOverride params on
walkAncestorsAndExpand.

* rf-vval: split validators.ts (524 → 68 LOC orchestrator)

Decomposes the built-in validators into domain-grouped modules under
graph/validator/validators/. Public API and behavior preserved verbatim
— the orchestrator file becomes a thin re-export shim plus the two
factory functions.

  - validators/structure.ts (163) — Cycle, Reference, Naming,
    Connectivity (graph topology + names, no schema dep)
  - validators/schema.ts (195) — Type, Property (schema-provider deps)
  - validators/security.ts (138) — SensitiveData, BestPractices
  - validators.ts (68) — re-exports + create_builtin_validators +
    create_configured_validator factories

Tests: 3 baseline (core.test.ts Graph Validator suite) + 70 new
sub-module tests = 73 total passing. Coverage on
validators/{structure,schema,security}.ts + validators.ts: 100%
statements / 100% branches / 100% functions / 100% lines.

The public exports from packages/core/src/index.ts (CycleValidator,
ReferenceValidator, ..., create_builtin_validators,
create_configured_validator) remain wired through the orchestrator
shim, so external consumers and the dist barrel don't change.

* rf-ierr: split import-errors.ts (507 → 30 LOC orchestrator)

Decomposes the import error classification system into per-cloud
modules under errors/import-errors/. Behavior + every error message
string preserved verbatim (user-facing strings are stable).

  - import-errors/types.ts (117) — ImportErrorCode enum,
    ImportErrorAction, ImportError, ImportWarning, ImportErrorActionType
  - import-errors/gcp.ts (145) — classifyGCPError
  - import-errors/aws.ts (144) — classifyAWSError
  - import-errors/azure.ts (119) — classifyAzureError
  - import-errors.ts (30) — re-export shim, public API unchanged

Tests: 0 baseline + 87 new sub-module + shim tests = 87 total. Coverage
on import-errors/{types,gcp,aws,azure}.ts: 100% statements / 100%
branches / 100% functions / 100% lines. The shim file shows 0% in v8
coverage (re-only-export quirk; the named imports through it are
exercised by the dedicated shim test file).

External consumers (aws-importer.ts, azure-importer.ts,
gcp/services/asset-inventory.ts, errors/index.ts barrel) keep their
original `from '../../errors/import-errors.js'` import paths.

Full core suite: 2472 tests still pass.

* rf-cmove: append learning on tri-state setter directive pattern

* Phase 3 complete: all 18 files in 500-600 LOC band refactored

64 commits across 6 cohorts. 9756 → 3441 LOC orchestrators (-65%).
+1356 tests added. 3 pre-existing bugs surfaced and documented
(preserved verbatim per refactor discipline).

Remaining files over 500 LOC are documented exceptions only:
- Generated (resource-types.ts)
- Data-heavy (high-level-resources, scale-presets-data, cloud-blocks-data,
  themes, ast/types)
- Refactored orchestrators with residual cohesion (deploy.service,
  svg-canvas, system-prompt)

The actionable refactor queue is empty.

* rf-deploy2-1: extract planDeployment + fallbackPlan to plan-deployment.ts

Pulls planDeployment (~85 LOC) and its module-private fallbackPlan
(~44 LOC) out of deploy.service.ts into a dedicated module. The
orchestrator re-exports planDeployment so the namespace import in
routes/canvas-deploy.ts and the export * in services/deploy/src/index.ts
keep resolving unchanged.

deploy.service.ts: 1572 → 1449 LOC.
Adds plan-deployment.test.ts covering happy path + translator-throw fallback.

* rf-deploy2-2: extract applyDeployment to apply-deployment.ts

Pulls applyDeployment (~573 LOC) — the 5-phase translate/auth/deploy
pipeline — out of deploy.service.ts into a dedicated module. The
orchestrator re-exports applyDeployment so the namespace import in
routes/canvas-deploy.ts and the named import in queue.service.ts
continue to resolve unchanged.

deploy.service.ts: 1449 → 894 LOC.
apply-deployment.ts: 600 LOC (slightly over 500-line ceiling; flagged
for follow-up internal split per brief).

* rf-deploy2-3: extract destroyAllForCard to destroy-all-for-card.ts

Pulls destroyAllForCard (~242 LOC) — the 'nuke' path that destroys every
ICE-managed resource for a card across all historical deployments — out
of deploy.service.ts into a dedicated module. The orchestrator re-exports
destroyAllForCard so the namespace import in routes/canvas-deploy.ts
keeps resolving unchanged.

deploy.service.ts: 894 → 644 LOC.
destroy-all-for-card.ts: 281 LOC.

* rf-deploy2-4: extract destroyDeployment to destroy-deployment.ts

Pulls destroyDeployment (~338 LOC) — the latest-apply-baseline destroy
path with reverse-order delete loop and pdl-10 per-resource node_status
emit — out of deploy.service.ts into a dedicated module. The orchestrator
re-exports destroyDeployment so the namespace import in
routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 644 → 311 LOC.
destroy-deployment.ts: 364 LOC.

* rf-deploy2-5: extract rollbackDeployment to rollback-deployment.ts

Pulls rollbackDeployment (~187 LOC) — the rebuild-from-target-deployment
diff/deploy path — out of deploy.service.ts into a dedicated module.
The orchestrator re-exports rollbackDeployment so the namespace import
in routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 311 → 129 LOC.
rollback-deployment.ts: 210 LOC.

* rf-deploy2-6: orchestrator slim-down — drop unused imports + docstring

Trims now-unused imports from deploy.service.ts (acquireDeployLock,
DeployLockError, providerService, getResourceMap, etc.) and updates the
top-of-file docstring to describe the file's new role: a thin re-export
shim plus 5 small DB-only helpers and the snapshot-persister side-effect
init. Body is unchanged behaviorally.

deploy.service.ts: 129 → 107 LOC (1572 → 107 across the rf-deploy2 series,
93% reduction). Public API surface preserved: every symbol exported
pre-refactor still resolves through the orchestrator (planDeployment,
applyDeployment, destroyAllForCard, destroyDeployment, rollbackDeployment,
getDeploymentStatus, getDeployedResources, getDeploymentHistory,
requestDeployCancel, getCurrentDeploySnapshot, DeployProgressSnapshot,
getNodeDeploymentOverlay, checkDrift).

* rf-deploy2-2 housekeeping: split apply-deployment.ts under 500 LOC

apply-deployment.ts landed at 600 LOC after rf-deploy2-2 (just over the
project's 500 LOC ceiling). This follow-up extracts five mechanically-
separable helpers into apply-pipeline-helpers.ts, bringing the orchestrator
under the ceiling without changing the apply pipeline's behavior:

- logSourceRepoDiagnostics: pre-deploy canvas-shape diagnostic log lines
- ensureAutoDeployRules: best-effort Source.Repository → Compute auto-rule writer
- logDiffForDebugging: console-only desired-vs-current node dump
- normalizeIdempotentResultErrors: NOT_FOUND/ALREADY_EXISTS rewrites
- persistResourceMappings: post-deploy mapping-table writes (in-place mutation)

apply-deployment.ts: 600 → 493 LOC.
apply-pipeline-helpers.ts: 220 LOC.
Adds 11 tests covering the three helper shapes that benefit most from
isolated test coverage.

* rf-deploy2: append awk-line-range learning from rf-deploy2-2

* rf-hlres-1: extract HighLevel* type interfaces to high-level-resources/types.ts

Pulls the 5 type interfaces (ProviderImplementation, HighLevelResource,
OptionDetail, HighLevelProperty, HighLevelCategory) plus the NodeBehavior
re-export out of the orchestrator. The shim now re-exports each name
verbatim, preserving the public API for both packages/core/src/resources/index.ts
and packages/core/src/index.ts.

Smoke test pins:
- the types module is loadable as a namespace
- structurally-typed values for each of the 5 interfaces compile
- the shim re-exports the 5 type names + NodeBehavior
- HIGH_LEVEL_CATEGORIES still has the 7 canonical category ids in order

Baseline typecheck preserved at 29 TS2834 errors (all in unrelated barrel
files); no new errors in touched paths.

* rf-hlres-2: extract compute category to high-level-resources/categories/compute.ts

Cuts the 'compute' object literal (~1779 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES and re-imports it as a const. The orchestrator
shrinks from 6354 to 4577 LOC.

Size exception documented in the file header — the file is dominated by
data (per-resource property catalogues, instance-size pickers, provider
implementations) and would only fragment further without improving
readability.

Smoke test pins the 13 canonical resource ids in order plus the shape
of frontend-app and backend-api. Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-3: extract database category to high-level-resources/categories/database.ts

Cuts the 'database' object literal (~2167 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 4577 to 2411 LOC.

Size exception documented in the file header — same shape as compute.

Smoke test pins the 13 canonical database resource ids in order
(postgres-db through search-engine) plus the shape of postgres-db
and redis-cache. Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-4: extract storage category to high-level-resources/categories/storage.ts

Cuts the 'storage' object literal (~449 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 2411 to 1964 LOC.

Smoke test pins the 5 canonical storage resource ids in order
(object-storage, oss, oci-object-storage, do-spaces, file-storage).
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-5: extract networking category to high-level-resources/categories/networking.ts

Cuts the 'networking' object literal (~559 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1964 to 1407 LOC.

Smoke test pins the 7 canonical networking resource ids in order
(public-endpoint, vpc-network, subnet, load-balancer, cdn, api-gateway,
dns-zone). Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-6: extract messaging category to high-level-resources/categories/messaging.ts

Cuts the 'messaging' object literal (~842 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1407 to 567 LOC.

Smoke test pins the 7 canonical messaging resource ids in order
(message-queue, event-bus, rabbitmq, cloud-pubsub, service-bus,
email-service, event-stream) and that message-queue carries the deep
optionDetails arrays used by the AWS / GCP / Azure queue-type picker.
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-7: extract security + monitoring categories (combined unit)

Cuts the last two object literals (security ~185 LOC, monitoring ~185 LOC)
verbatim out of HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 567
to 200 LOC — 7 categories now imported, no inline literals remain in
HIGH_LEVEL_CATEGORIES.

Both files are well under the 500 LOC ceiling; split out for symmetry
with the rest of the categories sub-tree.

Smoke tests pin the canonical resource ids in order: security
(secret-store, ssl-certificate, service-account) and monitoring
(log-group, alert, dashboard). Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-8: extract helpers + HIGH_LEVEL_CATEGORIES to high-level-resources/helpers.ts

Moves the 7 helper functions (getAllHighLevelResources, palette projection,
filterResourcesByProvider, getBehaviorLabel/Color, getGCPCloudAssetTypes,
cloudAssetToHighLevelType) plus the PULUMI_TO_CLOUD_ASSET map and the
HIGH_LEVEL_CATEGORIES assembly into a new helpers module.

helpers.ts owns the runtime origin of HIGH_LEVEL_CATEGORIES because the
cloud-asset helpers iterate over it — making the helpers the runtime owner
avoids a 'helpers → orchestrator → helpers' cycle. The orchestrator now
re-exports each runtime symbol verbatim so external consumers
(packages/core/src/index.ts, packages/core/src/resources/index.ts,
schema-bridge, property-rules, gcp asset-inventory, type-mapper) keep
the same import surface.

Exhaustive tests pin: HIGH_LEVEL_CATEGORIES order, getAll flatMap +
ordering, palette projection (ice_type / display_name / category /
behavior / providers / implementations / properties), provider filter
+ 'all' bypass + unknown provider returns empty, behavior label/color
delegation, GCP asset-types deduplication, and cloudAsset→HL reverse
lookup including null on unknown.

Orchestrator file: 202 → 37 LOC. Helpers: 195 LOC. Baseline typecheck
preserved at 29 TS2834 errors. Full @ice/core suite (2515 tests) passes.

* rf-hlres-9: orchestrator slim-down — finalize the high-level-resources shim

Polishes the orchestrator after rf-hlres-8 left it as a 37-LOC re-export
shim. Drops the in-progress wording from the docstring, merges the two
`export type {}` blocks into one, and adds section dividers so future
readers can spot the type re-exports vs. the runtime re-exports at a
glance.

Final file size: 47 LOC. Net delta on the rf-hlres series:
  - high-level-resources.ts: 6434 → 47 LOC (a ~99.3% shrink)
  - new files: types.ts, helpers.ts, 7 categories/<name>.ts
  - public API surface unchanged (every name still resolves on the shim)

Repo-wide test run after the slim-down: 8144 tests pass. Baseline
@ice/core typecheck preserved at 29 TS2834 errors (all unrelated).

* rf-hlres: append learning on helpers-owns-assembled-array pattern + awk per-splice tactics

* rf-svgcv2-1: extract pan/zoom transform group as CanvasContent

The inner <g transform=...> body — grid, selection frame, both connection
layers, clipPaths, nodes layer, connection-drawing preview, user-traffic
overlay, and ghost overlay — moves into canvas-renderer/canvas-content.tsx.
Visual draw order, prop flow, and gating logic preserved verbatim.

svg-canvas: 570 → 490 LOC.
canvas-content: 236 LOC (new).

Tests: 10 passing — direct-FC tree-walker matching mocks by reference.
Typecheck clean.

* rf-svgcv2-2: extract SVG mouse-event routing as useCanvasMouseRouting

The four inline arrow handlers on the <svg> element — onMouseDown
(connection-port classList sniff + bindCanvas fall-through), onMouseMove
(drawingConnection branch + bindCanvas fall-through), onMouseUp (same
shape), and onMouseLeave (tooltip dismiss + bindCanvas) — collapse into
a hook that returns the spread-able handler bundle.

Behavior preserved verbatim: classList sniff stays orchestrator-side (per
RISK #5), tooltip dismissal still fires on every onMouseDown/onMouseLeave,
onAuxClick / onContextMenu pass through bindCanvas references unchanged.

svg-canvas: 490 → 473 LOC.
use-canvas-mouse-routing: 114 LOC (new).

Tests: 12 passing — covers all four routing seams + identity-pass
through for AuxClick/ContextMenu. Typecheck clean.

* rf-svgcv2-3: extract selection-dispatch + snapToGrid wiring as useCanvasInteractionsBindings

The orchestrator's useCanvasInteractions call carried three inline
arrow-function callbacks (onSelect / onToggleSelect / onBoxSelect)
plus the snapToGrid → gridSize ternary. Both groups are now bundled in
a thin wrapper hook that calls useCanvasInteractions internally.

Behavior preserved verbatim — the same dispatch sequences fire for each
selection-change branch, and snapToGrid still toggles between GRID_SIZE
and 0. The wrapper drops the snapToGrid prop from the inner args object
so useCanvasInteractions sees only its own keys.

svg-canvas: 473 → 457 LOC.
use-canvas-interactions-bindings: 69 LOC (new).

Tests: 8 passing — covers each dispatch callback, the gridSize ternary,
and the snapToGrid prop-drop. Typecheck clean. Full canvas-tests suite:
207/207 passing.

* rf-svgcv2-4: extract RenderCtx assembly as useRenderCtx hook

The eighteen-field renderCtx object the per-node renderer dispatch
consumes — sortedNodes, lod, pipelineNodeStatus, drag/exit highlight
ids, the seven event-handler callbacks, etc. — moves into a
useRenderCtx hook. The hook also binds getConnectedPipelineStatuses
to (card, pipelineNodeStatus) so renderers call it as a single-arg
function (preserving rf-canv2-5's surface).

Behavior preserved verbatim: the returned ctx contains every field
the original literal had, with the same refs. card and zoom move
into the hook's args bundle; svg-canvas no longer imports
getConnectedPipelineStatuses or the RenderCtx type.

svg-canvas: 457 → 453 LOC.
use-render-ctx: 105 LOC (new).

Tests: 5 passing. Bonus: rf-svgcv2-3 test JSX-form fix (Provider needs
explicit children prop in TS strict — convert createElement(Provider,
{store}, child) to JSX form). Typecheck clean.

* rf-spr2-1: split system prompt into composable section builders

The 350-line static prose body of buildSystemPrompt's template literal
moves into seven section-builder functions in system-prompt-sections.ts:
  - buildHeaderPrompt(dominantProvider)
  - buildIntentRoutingPrompt() — WHEN TO ACT vs WHEN TO ASK
  - buildOperationsPrompt(availableBlockTypes) — STRICT BLOCK REGISTRY
  - buildPropertyPrefillPrompt() — PROPERTY PRE-FILL RULES
  - buildOptimizationGuidelinesPrompt() — improve security/cost/HA/...
  - buildCanvasContextPrompt(nodes/edges/selected/schema) — Current Canvas
  - buildContainerNetworkingPrompt(connectionPrompt) — VPC + WIRING

The orchestrator concatenates them via the + operator. Output is
byte-identical to the pre-rf-spr2 prompt — verified by a new snapshot
test (3 fixtures: bare canvas, cloud-architect skill, question intent
with deployment context). Pure helpers (formatNodesSummary,
detectDominantProvider, buildCloudArchitectPrompt) stay in
system-prompt.ts.

system-prompt.ts: 516 → 203 LOC.
system-prompt-sections.ts: 420 LOC (new, prose-heavy — header documents
the data-table exception).

Tests: 33 passing (30 existing + 3 new snapshot fixtures).
Typecheck clean.

* rf-asttyp-1: split AST types by category, keep ast/types.ts as re-export shim

The 581-LOC types.ts split into four sub-files by AST node category:
  - types/base.ts        — AstNode + AstNodeKind union (65 LOC)
  - types/expressions.ts — Expression + literals + access + splat +
                            for-comprehension types (253 LOC)
  - types/blocks.ts      — Block + Attribute + NestedBlock (44 LOC)
  - types/statements.ts  — Program + Statement union + every top-level
                            block + LifecycleConfig + ValidationRule +
                            TypeExpression (268 LOC)

ast/types.ts becomes a 70-LOC type-only re-export shim. Every existing
consumer (the parent ast.ts shim, ast-helpers.test.ts, plus the broader
parser/visit_ast machinery) keeps importing from '../ast/types.js'
unchanged.

Tests: 15 type-shape tests added in __tests__/types-shim.test.ts —
covers every kind discriminator + the optional-field shapes for
ResourceBlock/LifecycleConfig/TypeExpression. Full parser test suite:
297/297 passing. Typecheck: no new errors (existing 29 TS2834 baseline
errors unaffected per the ts2834-baseline learning).

* rf-svgcv2-4 followup: fix RenderCtx → Record cast in test (TS2352)

Two-step cast (as unknown as Record) to satisfy strict overlap check —
RenderCtx has no index signature so the direct cast fails.

* rf-svgcv2 + rf-spr2-1 + rf-asttyp-1: capture three new learnings

- byte-identity-snapshot-must-be-captured-pre-refactor-not-post (rf-spr2-1)
- svg-canvas-orchestrator-loc-budget-flexible-with-renderctx-bundling (rf-svgcv2-4)
- render-ctx-record-cast-needs-double-as-unknown-as-record (rf-svgcv2-4)

* rf-spdat: split scale-presets-data 1482 LOC into 7 category files + 46-LOC orchestrator

Extract SCALE_PRESETS dict by domain category (compute, database, storage,
networking, messaging, security, monitoring) under scale-presets-data/.
Orchestrator becomes a 46-LOC re-export shim that spreads each per-category
record into the SCALE_PRESETS dict — order preserved.

Per-category sizes (LOC): compute 387, database 500, messaging 265,
networking 166, storage 142, security 30, monitoring 30. All categories
data-only (size-exception applies).

Smoke test (scale-presets-data.test.ts): 14 tests pinning category counts,
no cross-category collisions, assembled-dict equals union, byte-identical
sample lookups for compute/database/monitoring tiers.

* rf-cbdat: split cloud-blocks-data 1009 LOC into 9 category files + 126-LOC orchestrator

Extract the 16 BlockTemplate entries by their declared `category:` field
under cloud-blocks-data/ (frontend, backend, compute, data, storage,
networking, messaging, observability, security). Orchestrator assembles
BLOCK_TEMPLATES in the documented original order and derives BLOCK_CATEGORIES
(palette grouping) by category-field filter.

Per-category sizes (LOC): backend 210, data 192, networking 128, messaging
106, security 106, frontend 79, storage 72, compute 70, observability 65.

Smoke test (cloud-blocks-data.test.ts): 17 tests pinning per-category
counts, no cross-category template-name collisions, byte-stable assembled
ordering vs. the original file, BLOCK_CATEGORIES references the assembled
list (filter, not clone).

* rf-thmdat: split themes 590 LOC into 3 4-theme groups + 30-LOC orchestrator

Extract the 12 ColorTheme entries into 3 group files (4 themes each) by
the original ordering: group-1 default/retro/cupcake/valentine (warm),
group-2 synthwave/coffee/luxury/aqua (bold), group-3 forest/sage/dracula/
night (nature/dark). Orchestrator becomes a 30-LOC re-export shim that
spreads the three groups into the public T array.

Per-group sizes: 200/201/201 LOC — each well within the 200-500 ceiling.

Smoke test (themes-data.test.ts): 12 tests pinning per-group counts and
ordering, no cross-group id collisions, byte-stable assembled T array,
reference-equality preserved through the spread (orchestrator FC depends
on T[i] identity for the shallow click-handler closures).

* Final round complete: all exceptions decomposed (excluding generated files)

8 exception files split: deploy.service, high-level-resources, svg-canvas,
system-prompt, ast/types, scale-presets-data, cloud-blocks-data, themes.

12,754 → 1,082 LOC across orchestrators (-91%). 28 commits, +180 tests.

Files over 500 LOC remaining: only the generated resource-types.ts
(excluded per user) and 4 high-level-resources category sub-modules
(pure resource-definition data with SIZE EXCEPTION headers; further
fragmentation would split related resources without maintainability
gain).

* bugfix-2: lazy require.resolve in get_base_db_path

The pre-fix code eagerly evaluated
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` while
constructing the candidate array, so the function threw before
reaching the existsSync loop in any environment where that package
isn't installed (test envs, fresh checkouts, this monorepo's dev
setup). Wrap each candidate in a thunk and the require.resolve
fallback in its own try/catch so a missing package degrades to
"skip this candidate" instead of crashing the whole call.

Tests:
- Confirm get_base_db_path() does not throw without the package.
- Confirm it returns a string ending in ice-schemas.db.
- Confirm it returns the dev path when staged at the resolved location
  (priority over the missing-package fallback).

Discovered during rf-cload-3 (extraction of `get_base_db_path` from
`customization-loader.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* bugfix-3: get_critical_path now propagates distance through chain

Pre-fix the distance update walked `get_incoming_edges` and read
the *source's* distance, but `topological_sort` emits leaves first
for `depends_on` graphs — so source nodes are processed AFTER the
current node and their distance is always -Infinity at lookup
time. The chain never propagated past the start node and the
function returned `[start]` for any DAG.

Fix: walk `get_outgoing_edges` (the current node's dependencies)
and read the *target's* distance. Targets are processed earlier
in topo order (leaves first), so the lookup is always populated.

Tests:
- 3-node chain a→b→c → returns [c, b, a] (was [c]).
- 4-node chain a→b→c→d → returns [d, c, b, a].
- Diamond DAG (a→b, a→c, b→d, c→d) → 3-node longest path.
- Isolated node → returns [solo].
- Empty graph → [].
- Cyclic graph → [].
- Disconnected components → returns longest among them.
- Two pre-existing softened tests (`expect(path.length)
  .toBeGreaterThanOrEqual(1)`) removed; replaced by strict
  assertions that pin the corrected length and node order.

Path orientation: leaf-first → root-last, matching the natural
"start → end" reading of a critical path. For chain a→b→c the
"start" is c (no deps) and the "end" is a (most-dependent).

Discovered during rf-galg-4 (extraction from `graph/algorithms.ts`);
preserved verbatim then per the verbatim-preservation rule for
refactors. Now fixed as a behaviour-change PR per the rule that
pre-existing bugs documented in learnings get separate fix
tickets.

* bugfix-4: detectJsFramework now reads pnpm/yarn lockfile signals

Pre-fix `filesToCheck` had only 6 entries — `package.json`,
`Dockerfile`, `requirements.txt`, `go.mod`, `pom.xml`, `Cargo.toml`.
The detector iterated these and pushed found names into
`detectedFiles`, but the JS-ecosystem branch in `detectJsFramework`
checked `detectedFiles.includes('pnpm-lock.yaml')` /
`yarn.lock` — neither of which was in `filesToCheck`. So the
package-manager guess always fell through to npm regardless of
which lockfile the repo actually had.

Fix: add the three JS lockfiles to `filesToCheck` so they show up
in `detectedFiles` when present. Three extra Contents-API GETs per
detection call; cheap given GitHub's rate limits and one-shot
nature of this path.

Tests:
- pnpm-lock.yaml present → packageManager='pnpm',
  installCommand='pnpm install --frozen-lockfile'.
- yarn.lock present → packageManager='yarn',
  installCommand='yarn install --frozen-lockfile'.
- package-lock.json present → packageManager='npm' (default fall-
  through preserved; ladder has no explicit branch for it).
- No lockfile → packageManager='npm' (still works).
- pnpm + yarn both present → pnpm wins (ladder priority pinned).
- Removed the test that pinned the broken behavior ("package.json
  defaults to npm package manager (lock files are not in
  detectedFiles list)") — replaced by the corrected
  pnpm/yarn/no-lockfile triplet.

Bundles the SHA fixup for the bugfix-3 `_Fixed:_` line in
state/learnings.md.

Discovered during rf-pipe-6 (extraction of `framework-detection.ts`
from `pipeline.service.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* learnings: append bugfix-2/3/4 sweep gotcha (Fixed-line audit trail)

Capture the rule of thumb that emerged from the bugfix sweep: only
the rf-galg-4 quirk had a dedicated learning anchor; the other two
bugs were documented inline in their SUT header comments. The
brief assumed every bug had its own anchor for the `_Fixed:_`
audit trail; reality is sparser and the implementer should pick
between inline-doc and learning-anchor based on whether the
deferred fix is a real future ticket.

* progress: archive entry for completed LOC discipline initiative

Multi-day refactor work (2026-04-29 → 2026-05-02) marked complete in
the Archive section of progress.md. Cross-references to phase totals,
bug fixes, and the new /docs/refactoring-patterns.md doc.

Cumulative: ~470 commits, ~7500 new tests, 73 files refactored,
4 latent bugs fixed, 1 generated file excluded, 4 data-leaf
exceptions remaining (documented).

Codebase decomposition is complete. Pausing here.

* progress: housekeep In flight, move pdl + rf-* detail trail to Archive

Reduces progress.md from 322 → 216 lines. In flight section now contains
just the 6 deferred follow-ups (pdl-11, rollupPercentage, nodesById warm-
seed, dead snapshot fields, data.status fallback, 3 rf-0c dedups). All
pdl-1..10 and rf-deploy/rf-props/rf-canv/rf-pdpl/rf-ctrans/rf-cards/
rf-fbh/rf-parse subsections moved verbatim under Archive so anchor/commit
references remain searchable.

* decisions: 2026-05-02 merge-story entry for refactoring branch

Document the choice to merge the 509-commit refactoring branch as a
single PR rather than splitting per phase. Rationale and alternatives
captured in the decision entry; consequences note that deferred follow-
ups (pdl-11 etc.) ship as separate PRs after the merge.

* fix(test): cast source_node_id read in apply-pipeline-helpers test

The helper mutates result.resources[i].source_node_id at runtime via
res: any. The test fixture's inline literal infers a strict type
without source_node_id, so the post-call assertion fails TS2834.
Add { source_node_id?: string } cast at the two assertion sites to
match the runtime mutation contract.

* state: archive pre-compaction snapshot of learnings.md (2026-Q2)

Verbatim copy of learnings.md before the Q2-2026 compaction pass.
Per CLAUDE.md schedule: cluster duplicates once per quarter, archive
the pre-compaction file as state/archive/learnings-YYYY-Qn.md, then
write the compacted version back. The compacted version lands in the
next commit.

204 anchors / 1676 lines preserved here for reference.

* state: compact learnings.md (Q2-2026 pass)

Cluster duplicates and trim redundancy after the parallel-deploy +
LOC-discipline initiatives. 204 → 113 anchors (-44%), 1676 → 780
lines (-53%). All 25 must-preserve anchors retained verbatim (the 24
referenced from /docs/refactoring-patterns.md plus the read-state-first
anchor cited in decisions.md and CLAUDE.md). Both _Promoted to:_ and
_Fixed:_ trailers preserved verbatim.

Representative cluster merges (canonical ← folded):
- ux-log-terminal-pitfalls ← 5 ux-log-* siblings
- ux-deploy-real-cloud-pitfalls ← 3 ux-deploy-* siblings
- pdl-7-wire-contract-trims-downstream-ui ← 3 pdl-7 follow-ups
- pdl-10-destroy-snapshot-and-dedup-traps ← 3 pdl-10 follow-ups
- ux-pdl-smoke-test-pitfalls ← 3 ux-pdl-* siblings
- inline-classification-duplications-are-not-actually-duplicates
  ← 4 sibling rf-canv anchors about inverted tie-breaks
- test-helper-defaults-traps-coalesce-and-spread ← 3 helper-default traps
- brief-numerics-are-approximate-source-is-canonical ← absorbed 3 brief-vs-source variants

Pre-compaction snapshot archived in the prior commit at
state/archive/learnings-2026-Q2.md.

* pdl-11: default node.data.provider to active deploy provider on drop

Closes the deferred follow-up from the parallel-deploy initiative
recorded in state/progress.md In flight. Palette drops without an
explicit `application/ice-block-provider` key (or with the 'all'
sentinel) now fall back to `state.deploy.provider`, so the deploy
panel doesn't filter newly-dropped blocks as "skipped — non-<provider>"
when the toolbar provider is set.

Three drop branches:
- Group drop: unchanged (groups have no provider).
- Block drop: paletteProvider 'all' or unset → effectiveProvider
  becomes deployProvider; threaded into both getBlueprint and
  expandBlueprint. Explicit palette providers (aws/azure) still win.
- Resource drop: newNodeData.provider is set to deployProvider.

logBlueprint still records the *palette* provider (analytics tracks
user intent, not the post-fallback value).

Tests: 21 → 23 in use-canvas-drop.test.tsx (+ 2 pdl-11 cases:
fallback-when-palette-omits, palette-wins-over-deploy-provider). One
existing test assertion updated from `provider: 'all'` to `provider:
'gcp'` to match the new behavior. Resource-drop case asserts
`data.provider === 'gcp'` (default deploy provider in the test store).

* deploy: extract deriveRollupPercentage helper from 3 inline copies

Closes pdl-5 critic findings #2 and #4 (deferred to follow-up). The
cap-at-99 progress formula was inlined identically in three places:
  - features/deploy/components/deploy-in-flight-panel.tsx:50
  - features/canvas/components/deploy-banner.tsx:94
  - shared/components/status-bar.tsx:216

Extracted to deploy/derive.ts next to deriveRollup as a pure projection
from DeployRollup → 0..100 percentage. Re-exported from deploy-slice
alongside deriveRollup / orderNodesForPanel so existing import paths
keep resolving.

Tests: 5 new dedicated cases for deriveRollupPercentage (empty / full /
cap-at-99 boundary / rounding / defensive zero-total). The
deploy-in-flight-panel.test mock switched to importOriginal +
selective override so deriveRollupPercentage runs real (it's a pure
projection from the rollup the test already controls via
mocks.deriveRollup) — saves maintaining a parallel mock that has to
mirror the cap formula.

19 → 19 derive tests (+5), 53 → 53 deploy-in-flight tests (refactor
preserves behavior), 0 typecheck regressions.

* deploy: warm-seed nodesById from snapshot in Phase 2 (pdl-5 #7)

Closes pdl-5 critic finding #7 (deferred to follow-up). When a tab
joins mid-deploy, Phase 2 of useDeploySubscription pulls the persisted
snapshot from the gateway and was previously only mirroring the
overlay onto the canvas blocks. The deploy panel's per-row list,
which derives from `nodesById`, stayed empty until Phase 2.5's replay
loop completed — leaving a brief "Preparing…" sentinel window even
though the per-block overlays already showed the right colors.

The warm-seed dispatches synthetic `node_status` events (and
`node_progress` when the snapshot carries a step descriptor) for each
node in `snapshot.nodeStatuses`, so the panel's per-row list renders
immediately.

Synthetic events use seq=0 so any live event (or replayed event from
Phase 2.5) with seq>0 dedup-wins on the same node, overwriting the
warm-seed entry's empty resource_name / resource_type / action with
the authoritative wire values.

New helper: overlayToWireStatus inverts mapWireStatusToOverlay
(snapshot stores the post-mapping overlay string; warm-seed walks it
back to a wire DeployNodeStatus). Returns null for overlay strings
that don't round-trip — e.g. 'destroying' / 'gone' from pre-pdl-10
destroy paths — so warm-seed skips them and Phase 2.5 fills them in
from the event tape.

Tests: 14 → 17 in use-deploy-subscription.test.ts (+3 for
overlayToWireStatus: full inverse map, null fallback for unknown
strings, round-trip through mapWireStatusToOverlay).

* deploy: drop DeployProgressSnapshot dead fields

The frontend stopped reading snapshot.progress, snapshot.currentResource,
and snapshot.currentStep when pdl-5 rewired the deploy panel + canvas
banner to derive every in-flight signal from `nodesById` (populated by
the typed node_status / node_progress wire). The server-side writers
have been writing dead state ever since.

Removed:
- progress: number, currentResource?, currentStep? from
  DeployProgressSnapshot interface (deploy-locks.ts).
- progress: 0 seed in startDeploySnapshot.
- progress: 100 stamp in finishDeploySnapshot.
- updateDeploySnapshot helper (no remaining callers after this drop).
- progress / currentResource writes in scheduler-callbacks (the totals
  count bump is preserved — callers still read it after the deploy
  returns).
- progress: 0 in canvas-deploy.ts /current/:cardId fallback.

The Json column on the Prisma `canvas_deployment.snapshot` field is
schemaless, so removing keys from the TS type doesn't migrate. Old DB
rows still carry these fields; new rows won't. The shared-modules
registry entry for the deploy-locks export set was trimmed to match.

Tests: 32 → 30 in scheduler-callbacks (caps-at-99 + write-only-
currentResource cases were specifically about the dead-field semantics
and dropped; the count-bump-on-terminal and per-node-mirror cases
preserved).

* ui: drop data.status legacy fallback per one-status-source-deploy-status

The compact-node deploy-pill render path read
`(data.deploy_status as string) || (data.status as string) || ''`,
making `data.status` a de-facto status source no matter how careful
the writer side stayed. Per the `one-status-source-deploy-status`
learning, removing the OR makes `deploy_status` the single source of
truth.

Coordinated with the writer-side sweep so no node-creation site seeds
`status: 'active'` anymore — the field stays empty until the deploy
pipeline writes `deploy_status`.

Sites cleaned (writer side):
- packages/blocks/src/{aws,azure,gcp}/security/waf.ts (3 WAF blueprints)
- packages/blocks/src/expand-blueprint.ts (mergedData seed)
- packages/templates/src/expand-template.ts (group + fallback resource)
- packages/ui/src/features/canvas/hooks/use-canvas-drop.ts (group + resource branches)
- packages/ui/src/store/slices/cards/reducers/undo-redo-group.ts
  (groupSelectedNodes new container)

Drift-checker writes (use-drift-check.ts) re-routed from `status:
'drifted' | 'active'` to `deploy_status:`-prefixed equivalents — drift
state IS a deploy outcome, not a separate field.

Reader side:…
julia-kafarska added a commit that referenced this pull request May 20, 2026
* rf-rval-1: extract type-checker and validation types from resource-validator

Moves the public Validation* types into resource-validator-types.ts so
helper modules can import them without dragging in the orchestrator
class. The shim file `resource-validator.ts` re-exports every type from
the new sibling file -> public API unchanged.

Pulls `validate_type` and `get_type_name` (private methods of
ResourceValidator) into validation/type-checker.ts as standalone pure
functions. The 'object'/'map' shared check, NaN-as-number-mismatch, and
'any' fall-through behaviour are preserved verbatim. 26 tests cover
every branch including unknown-type fall-through.

resource-validator.ts: 543 -> 374 LOC.

* rf-rval-2: extract constraint validation from resource-validator

Pulls `validate_constraints` (private method on ResourceValidator) into
schema/validation/constraints.ts with five named helpers:
  check_enum, check_pattern, check_numeric_range, check_string_length,
  check_array_length, plus a `validate_constraints` orchestrator.

Behaviour preserved verbatim: invalid regex strings silently swallowed,
inclusive boundaries on min/max and min_length/max_length, canonical
issue order (enum, pattern, range, length). Each helper independently
testable. 25 new tests pin every branch including issue ordering.

resource-validator.ts: 374 -> 258 LOC.

* rf-rval-3: extract property-validator and error-conversion

Pulls the recursive `validate_property` walker into
schema/validation/property-validator.ts and the
`to_validation_error` mapper into schema/validation/error-conversion.ts.

The class methods on ResourceValidator now delegate, including the
public `to_validation_error`, `is_valid`, and `validate_property_value`.
The recursive walker is byte-identical (required-missing, type-mismatch,
constraint, and nested object/array branches preserved); 17 new tests
pin its behaviour, plus 5 for the error converter.

resource-validator.ts: 258 -> 157 LOC. File 2 series complete.

* rf-cload-1: extract example-file content + paths from customization-loader

Pulls the four `_example.<ext>.disabled` content blocks (provider JSON,
override YAML, custom resource YAML, relationships YAML) out of the
inline `create_example_files` private method into named constants in
schema/customization/example-files.ts. The CustomizationPaths type and
subdir constants move to schema/customization/paths.ts.

Customization-loader.ts re-exports CustomizationPaths from the new
location so external consumers' import paths are unchanged. Behaviour
preserved: each file only created when missing, content byte-identical.
9 new tests pin both the constants and the directory write-out.

customization-loader.ts: 521 -> 410 LOC.

* rf-cload-2: extract per-file validators from customization-loader

Pulls the four `validate_*_file` private methods (provider JSON, override
YAML, custom resource YAML, relationships YAML) into
schema/customization/file-validators.ts as standalone async functions,
each returning `FileValidationResult`. The CustomizationError and
ValidationWarning types move to that file too — the orchestrator
re-exports them so external consumers (schema/index.ts) keep their
import paths.

Behaviour preserved verbatim: "Invalid JSON: ..." / "Invalid YAML: ..."
prefixes on parse failures, 1-indexed relationship error messages,
provider resources without properties emit warnings (not errors). 16
new tests drive each helper end-to-end via tmp file writes.

customization-loader.ts: 410 -> 261 LOC.

* rf-cload-3: extract directory scanner + base-db resolver

Pulls the `scan_directory` private method into
schema/customization/scanner.ts (along with the CustomizationFile type
that the scanner produces) and the `get_base_db_path` standalone
function into schema/customization/base-db.ts. The orchestrator
re-exports CustomizationFile + delegates `get_base_db_path` so external
imports are unaffected.

Behaviour preserved verbatim including the pre-existing buggy
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` eagerness
(noted in the test file's docstring; not in scope to fix here). 7 new
tests for the scanner driving real fs reads via tmp dirs.

customization-loader.ts: 261 -> 204 LOC. File 3 series complete.

* state: add three learnings from P3 cohort 3 (rf-esp / rf-rval / rf-cload)

- ts2834-baseline-error-moves-with-the-import: extracting a TS2834
  baseline import into a deeper subdirectory relocates the error,
  doesn't introduce one.
- fs-existssync-is-non-configurable-under-vitest-esm: vi.spyOn fails
  on node:fs namespace imports; use chdir into tmpdir instead.
- one-source-of-truth-for-types-in-shim-refactors: when decomposing
  a class file, public types live in a sibling <name>-types.ts to
  avoid cycles between the shim and helper modules.

* rf-pimp-1: extract state parsing helpers from pulumi state-importer

Pulls six pure helpers out of state-importer.ts into a new
parsing.ts module:

  - get_deployment           — read PulumiDeployment from either state shape
  - get_stack_info           — derive {stack,project} from checkpoint or URN
  - extract_name_from_urn    — last-segment fallback when parse_urn fails
  - is_secret_value          — sentinel-UUID check for Pulumi secret wrappers
  - unwrap_secret            — ciphertext > plaintext > value resolver
  - create_empty_metadata    — unknown-sentinel PulumiImportMetadata for errors

state-importer.ts: 564 -> 481 LOC.

22 new tests in parsing.test.ts pin the byte-identical behaviour
(secret sentinel UUID, both state shapes, URN fallback path,
empty-metadata defaults). All 79 existing pulumi/terraform tests
still pass; TS2834 baseline (29) unchanged.

* rf-pimp-2: extract resource conversion from pulumi state-importer

Pulls the resource-shape conversion out of state-importer.ts into a
new resource-conversion.ts module:

  - process_properties — recursive property walker with secret unwrap
  - import_resource    — PulumiResource -> PulumiImportedResource

Both are pure (warnings array is mutated like before).  Behaviour
preserved verbatim:
  - URN parse + extract_name_from_urn fallback for name
  - outputs > inputs precedence (with NO_OUTPUTS warning fallback)
  - dependencies = explicit ++ parent (parent appended last, in order)
  - additional_secret_outputs mirrored verbatim
  - protect/external default false; id passes through

state-importer.ts: 481 -> 387 LOC.

18 new tests pin the secret-mask path, the warning emission path, the
parent-last dependency append, and the protect/external defaults.
86 total importer tests pass; TS2834 baseline (29) unchanged.

* rf-pimp-3: extract graph conversion from pulumi state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph — PulumiImportResult -> MutableGraph
  - import_pulumi_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer and
the existing 79 importer tests bind directly to it.

state-importer.ts: 387 -> 278 LOC (564 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/stack/project)
  - per-node labels (provider, pulumi_type, optional protected/external)
  - per-node provenance annotations (imported_from, pulumi_urn)
  - resource.id lifted to node properties.id
  - self-dependency and missing-target edges skipped
  - target_graph merge mode preserves nodes only (edges already dropped)

12 new tests cover the node properties/labels/annotations, the edge
filters (self-loop + missing target), and the graph metadata.  98
total importer tests pass; TS2834 baseline (29) unchanged.

* rf-timp-1: extract sensitive-attribute masking + empty metadata

Pulls three pure helpers out of state-importer.ts into a new
sensitive.ts module:

  - mask_sensitive_attributes — top-level walker over sensitive paths
  - mask_path                 — recursive leaf-mask mutator
  - create_empty_metadata     — unknown-sentinel ImportMetadata for errors

state-importer.ts: 547 -> 514 LOC.

15 new tests pin the path-tokenisation behaviour:
  - top-level / dotted / bracket-array path forms
  - non-object intermediates abort the walk (no error)
  - missing leaves and null intermediates are no-ops
  - empty path / empty-input early return

All 79 existing pulumi/terraform tests still pass; TS2834 baseline
(29) unchanged.

* rf-timp-2: extract resource conversion + dependency inference

Pulls three functions out of state-importer.ts into a new
resource-conversion.ts module:

  - import_resource_instance — (resource, instance) -> ImportedResource
  - infer_dependencies       — id/arn-driven dep inference post-pass
  - scan_for_references      — recursive walker over property tree

state-importer.ts: 496 -> 368 LOC.

Behaviour preserved verbatim:
  - address = [module.]type.name[index_key], JSON-encoded index_key
  - ICE name = name_prefix + name + (_index_key when present)
  - sensitive_attributes path masking + SENSITIVE_MASKED warning
  - explicit instance.dependencies pass-through
  - id_lookup indexed by both 'id' AND 'arn' property values
  - Set-seeded dedup union of explicit + inferred deps
  - dependencies[] mutated in place per resource

22 new tests cover address building (incl. module/index_key paths),
the masking warning emission, the include_sensitive=true bypass, and
all three sub-cases of scan_for_references (string/array/object/null).
65 total terraform tests pass; TS2834 baseline (29) unchanged.

* rf-timp-3: extract graph conversion from terraform state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph    — TerraformImportResult -> MutableGraph
  - import_terraform_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer.

state-importer.ts: 368 -> 268 LOC (547 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/lineage)
  - per-node labels (provider, terraform_type, optional module)
  - per-node provenance annotations (imported_from, terraform_address)
  - all edges tagged 'inferred: true' regardless of origin
  - missing-target edges silently skipped
  - target_graph merge mode preserves nodes only

10 new tests cover the node properties/labels/annotations, the
optional module label, and the missing-target edge filter. 75 total
terraform importer tests pass; TS2834 baseline (29) unchanged.

* rf-aimp-1: extract ARN/tag helpers from aws-importer

Pulls four pure helpers out of aws-importer.ts into a new
arn-helpers.ts module:

  - extract_name_from_arn   — trailing /-or-:-separated name
  - extract_account_from_arn — 5th segment, '' when malformed
  - extract_region_from_arn  — 4th segment, 'global' default for IAM/CF
  - parse_tags               — Tags-array OR tags-object normalisation

aws-importer.ts: 533 -> 495 LOC.

Behaviour preserved verbatim:
  - 6-segment ARN check, segment-5+ join for resource portion
  - resource fallback when split-on-/-or-: leaves empty trailing
  - global default for empty region slot (IAM, CloudFront)
  - Tags array preferred over tags object when both exist
  - String() coercion for non-string Key/Value pairs

22 new tests cover all four helpers across well-formed ARNs,
malformed inputs, IAM-style global resources, and both tag formats.
TS2834 baseline (29) unchanged.

* rf-aimp-2: extract AWS SDK init from aws-importer

Pulls the dynamic-import wrapper functions out of aws-importer.ts into
a new sdk-init.ts module:

  - AWSSdk interface  — STS / ResourceExplorer / ConfigService bundle
  - init_aws_sdk      — dynamic-import client-sts/-resource-explorer-2/
                        -config-service, optional fromIni({profile})
  - get_account_id    — STS GetCallerIdentity wrapper, 'unknown' on err

aws-importer.ts: 495 -> 444 LOC.

Behaviour preserved verbatim — including the load-bearing
'Function("m", "return import(m)")' pattern that prevents bundlers
from transpiling the dynamic import to a static require (which would
break the optional-dep guarantee for users who never use AWS).

5 new tests cover the friendly install-the-sdk error path and the
'unknown' fallback in get_account_id.  TS2834 baseline (29) unchanged.

* rf-aimp-3: extract resource discovery from aws-importer

Pulls the two paginated AWS-API discovery loops out of aws-importer.ts
into a new discovery.ts module:

  - map_resource_explorer_hit       — pure: hit -> AWSResource
  - map_config_result               — pure: JSON-string -> AWSResource|null
  - discover_with_resource_explorer — paginated SearchCommand wrapper
  - discover_with_config            — paginated SelectResourceConfig wrapper

Two pure mappers were extracted alongside the discover_*() loops to
make the response-shape -> AWSResource conversion testable without
needing to stub the dynamic @aws-sdk/client-* imports (the
'Function("m", "return import(m)")' indirection bypasses any
Vitest module registry).

aws-importer.ts: 438 -> 358 LOC.

Behaviour preserved verbatim:
  - Resource Explorer: QueryString='*', MaxResults=100, NextToken pagination
  - Config: SelectResourceConfigCommand SQL DSL (LIKE '%')
  - region default 'global' for both paths
  - resourceId preferred over ARN-derived name (Config only)
  - JSON.parse failures silently skipped (Config only)

8 new tests cover the pure mappers across well-formed / partial /
malformed inputs, plus the SDK-not-installed failure path on each
discover_*() entrypoint.  TS2834 baseline (29) unchanged.

* rf-aimp-4: extract graph conversion + relationship inference

Pulls the two graph functions out of aws-importer.ts into a new
graph-conversion.ts module:

  - aws_result_to_graph  — AWSImportResult -> MutableGraph
  - infer_relationships  — ARN-driven dep inference post-pass

aws-importer.ts re-exports aws_result_to_graph via barrel-style
'export {...} from' to preserve the public surface — index.ts and
the importers index both bind to it.  Local consumer
import_aws_to_graph aliases the local import as aws_result_to_graph_impl
to avoid same-name collision with the re-export.

aws-importer.ts: 346 -> 250 LOC (533 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source, account_id)
  - per-node labels: provider, aws_type, account_id, region, ...tags
    (tags spread last — tags WIN on key collision with canonical labels)
  - per-node provenance: imported_from, aws_arn, aws_account
  - depends_on edges with inferred:true + source:aws labels
  - self-dependency and missing-target edges silently skipped
  - infer_relationships REPLACES dependencies (not unions) — load-bearing
  - ARN matching gated by 'arn:aws:' prefix and arn_set membership

19 new tests cover the canonical-label-vs-tag collision, the
self-dep/missing-target edge filters, dedup of repeated references,
own-ARN exclusion, and the dependency-replacement (not -union)
contract.  148 total importer tests pass; TS2834 baseline (29)
unchanged.

* state: add two learnings from P3 cohort 4 (rf-aimp series)

dynamic-import-indirection-blocks-test-mocks
  The Function('m', 'return import(m)') pattern in aws-importer
  bypasses Vitest's module registry — vi.mock can't stub the AWS
  SDK calls. Workaround: extract pure mappers and test those.

same-name-local-import-and-reexport-collision
  When an extracted function is both consumed locally AND re-exported
  for the public surface, alias the local import to make the two
  roles explicit (X_impl vs X).

* rf-tfexp-1: extract terraform types module

Verbatim port of TerraformExportOptions / RequiredProvider /
TerraformResource / TerraformLifecycle / TerraformConfig /
TerraformBlock / TerraformProviderConfig / TerraformVariable /
TerraformOutput / TerraformExportResult shapes from
terraform-exporter.ts (pre-extraction L20-160) into a dedicated
types module. Public surface preserved — re-exported from the
orchestrator in the slim-down unit.

* rf-tfexp-2: extract terraform sanitize_name helper

Verbatim port of the private sanitizeName method from
terraform-exporter.ts (pre-extraction L420-428) into a pure
helper module. The Terraform identifier rules differ from
Pulumi's (underscore prefix vs r- prefix; preserves _ in
identifiers); kept separate to avoid coupling.

Tests: 10 cases covering alphanumeric pass-through, dot/slash/
space substitution, leading-digit prefix, unicode replacement.

* rf-tfexp-3: extract terraform fallback_type_mapping helper

Verbatim port of the private fallbackTypeMapping method from
terraform-exporter.ts (pre-extraction L335-362). Provider-prefix
table preserved exactly; gcp/aws/azure branch ordering preserved
(a gcp.* type always hits the gcp branch even if provider token
is non-gcp). Documented the pre-extraction quirks where the aws
and azure branches hard-code the prefix regardless of provider
token.

Tests: 17 cases covering each branch + provider mapping table.

* rf-tfexp-4: extract terraform value-transform helpers

Verbatim port of mapProperties / transformValue / formatDependencies
from terraform-exporter.ts (pre-extraction L367-418). Three pure
transformations with no class-state dependency.

Key differences vs Pulumi's value-transform: keys are preserved
AS-IS (Terraform uses snake_case natively); transform_value does
not rename nested keys. format_dependencies emits # placeholders
(pre-extraction had a TODO comment about lookup; preserved).

Tests: 23 cases covering null/undefined normalisation, _-prefix
filtering at top level only, recursive nested transforms, array
handling, dependency placeholder formatting.

* rf-tfexp-5: extract terraform HCL formatter helpers

Verbatim port of formatHCLValue / toHCL / toJSON from
terraform-exporter.ts (pre-extraction L433-545). Pure functions;
no class state.

The HCL output format is byte-identical to the pre-extraction
class methods. Particularly load-bearing:
 - String escape order (backslash first, then quote)
 - null/undefined property values SKIPPED (not emitted as null)
 - depends_on block omitted when empty
 - HCL object syntax 'key = value' (not JSON-style 'key: value')
 - Trailing blank line after each section

Tests: 37 cases covering all value types + full to_hcl snapshot
regression guard.

* rf-tfexp-6: extract terraform converter helpers

Verbatim port of buildDependencyMap / nodeToResource /
exportGraph from terraform-exporter.ts (pre-extraction L189-330).
The class state previously held by the orchestrator
(schema_provider) is now passed as the first argument to each
helper.

Pre-extraction quirks preserved:
 - depends_on edge filter (other relationships ignored)
 - node.properties || {} defensive default
 - unmapped_types deduped via Set; warnings NOT deduped
 - format-selection branch checks 'json' literal; everything
   else (including undefined) emits HCL

* rf-tfexp-7: slim terraform-exporter orchestrator (558 -> 102 LOC)

The class is now a thin orchestration shell:
 - constructor instantiates schema_provider
 - initialize() lazy-initialises the schema provider
 - exportGraph() delegates to ./terraform/converter.export_graph

Public API unchanged — TerraformExporter, create_terraform_exporter,
and the eleven exported types all keep their pre-extraction shape.
External consumers (export/index.ts) continue importing through
the orchestrator path; the new terraform/* modules are internal.

* rf-pmap-1: extract pulumi type-mapper data tables

Verbatim port of PROVIDER_MAP (~24 entries) and TYPE_MAP
(~280 entries) from importers/pulumi/type-mapper.ts
(pre-extraction L94-413). The TYPE_MAP entries are the
SOURCE OF TRUTH for ICE iceType names — external consumers
depend on the exact dotted-form values; preserved verbatim.

Size exception: 376 LOC justified by data-only nature
(cf. /docs/refactoring-patterns.md 'Data-heavy shim split').

Tests: 18 cases pinning provider count, key existence, ICE
type format, dedup behaviour for collapsed mappings (e.g.
aws:s3/bucket:Bucket and aws:s3/bucketV2:BucketV2 both
mapping to aws.s3.bucket).

* rf-pmap-2: extract pulumi type-mapper URN/type parsers

Verbatim port of parse_urn and parse_type from
importers/pulumi/type-mapper.ts (pre-extraction L19-86).
Pure string parsers; no data-table dependency.

Tests: 19 cases covering URN parts validation, special
type handling (pulumi:pulumi:Stack, pulumi:providers:*),
standard format vs alternative format priority order,
malformed input fallback to empty object.

* rf-pmap-3: extract pulumi type-mapper mapping helpers

Verbatim port of get_ice_type, get_ice_provider,
get_provider_from_type, is_type_supported,
get_supported_types, get_supported_ice_types,
get_name_from_urn, is_provider_resource, is_stack_resource
from importers/pulumi/type-mapper.ts (pre-extraction
L422-527). Plus the private to_snake_case helper
(pre-extraction L500-505).

Pre-extraction quirks preserved:
 - get_ice_type three-stage fallback (TYPE_MAP -> synth -> lowercase dotted)
 - get_ice_provider three-stage fallback (URN -> type -> simple-name -> 'unknown')
 - is_type_supported strict TYPE_MAP membership (synthesised paths excluded)
 - get_supported_ice_types deduped via Set
 - to_snake_case strips leading underscore from ([A-Z]) capture

Tests: 21 cases covering all eight helpers + fallback paths.

* rf-pmap-4: slim pulumi type-mapper orchestrator (527 -> 42 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the (implicit) data-table re-exports keep
their pre-extraction shapes. External consumers
(state-importer.ts, parsing.ts, resource-conversion.ts,
index.ts) continue importing through this shim path
without changes.

* rf-galg-1: extract graph topological-sort + cycle detection

Verbatim port of topological_sort, reverse_topological_sort,
has_cycle, find_cycles, find_cycle_in_subgraph from
graph/algorithms.ts (pre-extraction L18-220). Grouped together
because topological_sort uses find_cycle_in_subgraph (private)
for error reporting; has_cycle is a one-line wrapper around
topological_sort.

Pre-extraction quirks documented:
 - topological_sort double-counts in-degree decrement (iterates
   outgoing edges + ALL edges with target === current)
 - find_cycles returns DUPLICATE cycles when nodes participate
   in multiple cycles (no dedup)
 - find_cycle_in_subgraph returns first cycle OR up to 5 nodes
   from input on failure (best-effort error fallback)

Tests: 16 cases covering linear chains, cycles, disconnected
nodes, non-depends-on edge filtering. Plus shared fixtures.ts
with make_graph helper for the rest of the rf-galg series.

* rf-galg-2: extract graph path-finding helpers

Verbatim port of find_all_paths and find_shortest_path from
graph/algorithms.ts (pre-extraction L229-297). Independent
DFS/BFS path finding; no dependency on topo/cycle/components.

Pre-extraction quirks documented:
 - find_all_paths uses recursive DFS with visited-set cycle
   avoidance + max_paths cap (default 100)
 - find_shortest_path uses Array.shift BFS (O(n) per shift,
   not O(1)); preserved verbatim
 - find_shortest_path returns null on no path, [start] when
   start === end

Tests: 12 cases covering direct edges, diamonds, cycle
avoidance, max_paths cap, null vs empty array.

* rf-galg-3: extract connected components helpers

Verbatim port of find_connected_components and
find_strongly_connected_components from graph/algorithms.ts
(pre-extraction L307-402). Two distinct algorithms:
 - find_connected_components: BFS, treats edges as UNDIRECTED
   (uses both incoming and outgoing edges)
 - find_strongly_connected_components: Tarjan's, treats edges
   as DIRECTED; filters single-node SCCs (length > 1 only)

Pre-extraction quirks documented:
 - find_connected_components tolerates duplicate enqueue (the
   visited.has check is at top of inner loop, not at enqueue)
 - find_strongly_connected_components excludes singleton SCCs
   (intentional — singleton means trivially-strongly-connected,
   so excluding gives meaningful SCCs only)
 - Recursive Tarjan can hit JS stack limits on deep graphs

Tests: 12 cases covering empty graphs, disconnected nodes,
diamonds, mutual cycles, multi-cycle graphs.

* rf-galg-4: extract dependency analysis + graph metrics

Verbatim port of get_execution_layers, get_critical_path,
calculate_metrics, GraphMetrics interface from
graph/algorithms.ts (pre-extraction L412-586). Depends on
helpers from topo-cycle and components modules.

Pre-extraction quirks documented:
 - get_execution_layers uses iterative layer-peel; on cycle
   produces empty layer and breaks (silent ceasing)
 - get_critical_path KNOWN BUG: distance update walks
   incoming-edge predecessors, but topo order processes leaves
   first for depends_on graphs, so the source-distance lookup
   reads -Infinity and the chain never propagates. Effectively
   returns just the start (no-deps) node. Preserved verbatim
   — fixing this changes public behaviour, out-of-scope for
   refactor.
 - calculate_metrics density formula: e/(n*(n-1)); guards
   max_edges > 0 to avoid div-by-zero on empty graphs

Tests: 15 cases covering empty graphs, parallelisable layers,
cycle detection, density edge cases, degree statistics. Two
critical-path tests assert length >= 1 (preserving the
pre-extraction quirk).

* rf-galg-5: slim graph algorithms orchestrator (586 -> 51 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the GraphMetrics type keep their pre-extraction
shapes. External consumers (graph/index.ts,
plan/plan-engine.ts, graph/validator/validators.ts) continue
importing through this shim path without changes.

* rf-pmap rf-galg: append learnings on critical-path quirk + data-table LOC exception

* rf-cmove: split use-container-move.ts (564 → 189 LOC orchestrator)

Decomposes the rf-canv-25b useContainerMove hook into pure runners
under hooks/container-move/. Behavior preserved verbatim — both
handleNodeMove and handleToggleFold delegate to the new sub-modules.

  - container-move/types.ts (25) — PositionUpdate / SizeUpdate
  - container-move/ancestor-expansion.ts (275) — walkAncestorsAndExpand,
    expandAncestorOnce (shared by both handlers, parameterized over
    sibling-bounds reading strategy)
  - container-move/clamp.ts (114) — clampDraggedNodeToParent +
    detectExitingGroupId (tri-state setter directive)
  - container-move/move-runner.ts (127) — runNodeMove pure runner
  - container-move/toggle-fold-runner.ts (187) — resolveToggleFoldDecision
    + runUnfoldExpansion pair

Tests: 27 baseline (use-container-move.test.tsx) + 68 new sub-module
tests = 95 total passing. Coverage on container-move/: 100% statements
/ 100% branches / 100% functions / 100% lines (types.ts excluded as
runtime-empty).

Per blueprint risk #2 — DO NOT consolidate the four ancestor-expansion
sites with rf-canv-4's expandToFitChildren; they have subtly different
rules. The split preserves the per-handler bbox-reading strategy via
the siblingPosLookup + siblingBoundsOverride params on
walkAncestorsAndExpand.

* rf-vval: split validators.ts (524 → 68 LOC orchestrator)

Decomposes the built-in validators into domain-grouped modules under
graph/validator/validators/. Public API and behavior preserved verbatim
— the orchestrator file becomes a thin re-export shim plus the two
factory functions.

  - validators/structure.ts (163) — Cycle, Reference, Naming,
    Connectivity (graph topology + names, no schema dep)
  - validators/schema.ts (195) — Type, Property (schema-provider deps)
  - validators/security.ts (138) — SensitiveData, BestPractices
  - validators.ts (68) — re-exports + create_builtin_validators +
    create_configured_validator factories

Tests: 3 baseline (core.test.ts Graph Validator suite) + 70 new
sub-module tests = 73 total passing. Coverage on
validators/{structure,schema,security}.ts + validators.ts: 100%
statements / 100% branches / 100% functions / 100% lines.

The public exports from packages/core/src/index.ts (CycleValidator,
ReferenceValidator, ..., create_builtin_validators,
create_configured_validator) remain wired through the orchestrator
shim, so external consumers and the dist barrel don't change.

* rf-ierr: split import-errors.ts (507 → 30 LOC orchestrator)

Decomposes the import error classification system into per-cloud
modules under errors/import-errors/. Behavior + every error message
string preserved verbatim (user-facing strings are stable).

  - import-errors/types.ts (117) — ImportErrorCode enum,
    ImportErrorAction, ImportError, ImportWarning, ImportErrorActionType
  - import-errors/gcp.ts (145) — classifyGCPError
  - import-errors/aws.ts (144) — classifyAWSError
  - import-errors/azure.ts (119) — classifyAzureError
  - import-errors.ts (30) — re-export shim, public API unchanged

Tests: 0 baseline + 87 new sub-module + shim tests = 87 total. Coverage
on import-errors/{types,gcp,aws,azure}.ts: 100% statements / 100%
branches / 100% functions / 100% lines. The shim file shows 0% in v8
coverage (re-only-export quirk; the named imports through it are
exercised by the dedicated shim test file).

External consumers (aws-importer.ts, azure-importer.ts,
gcp/services/asset-inventory.ts, errors/index.ts barrel) keep their
original `from '../../errors/import-errors.js'` import paths.

Full core suite: 2472 tests still pass.

* rf-cmove: append learning on tri-state setter directive pattern

* Phase 3 complete: all 18 files in 500-600 LOC band refactored

64 commits across 6 cohorts. 9756 → 3441 LOC orchestrators (-65%).
+1356 tests added. 3 pre-existing bugs surfaced and documented
(preserved verbatim per refactor discipline).

Remaining files over 500 LOC are documented exceptions only:
- Generated (resource-types.ts)
- Data-heavy (high-level-resources, scale-presets-data, cloud-blocks-data,
  themes, ast/types)
- Refactored orchestrators with residual cohesion (deploy.service,
  svg-canvas, system-prompt)

The actionable refactor queue is empty.

* rf-deploy2-1: extract planDeployment + fallbackPlan to plan-deployment.ts

Pulls planDeployment (~85 LOC) and its module-private fallbackPlan
(~44 LOC) out of deploy.service.ts into a dedicated module. The
orchestrator re-exports planDeployment so the namespace import in
routes/canvas-deploy.ts and the export * in services/deploy/src/index.ts
keep resolving unchanged.

deploy.service.ts: 1572 → 1449 LOC.
Adds plan-deployment.test.ts covering happy path + translator-throw fallback.

* rf-deploy2-2: extract applyDeployment to apply-deployment.ts

Pulls applyDeployment (~573 LOC) — the 5-phase translate/auth/deploy
pipeline — out of deploy.service.ts into a dedicated module. The
orchestrator re-exports applyDeployment so the namespace import in
routes/canvas-deploy.ts and the named import in queue.service.ts
continue to resolve unchanged.

deploy.service.ts: 1449 → 894 LOC.
apply-deployment.ts: 600 LOC (slightly over 500-line ceiling; flagged
for follow-up internal split per brief).

* rf-deploy2-3: extract destroyAllForCard to destroy-all-for-card.ts

Pulls destroyAllForCard (~242 LOC) — the 'nuke' path that destroys every
ICE-managed resource for a card across all historical deployments — out
of deploy.service.ts into a dedicated module. The orchestrator re-exports
destroyAllForCard so the namespace import in routes/canvas-deploy.ts
keeps resolving unchanged.

deploy.service.ts: 894 → 644 LOC.
destroy-all-for-card.ts: 281 LOC.

* rf-deploy2-4: extract destroyDeployment to destroy-deployment.ts

Pulls destroyDeployment (~338 LOC) — the latest-apply-baseline destroy
path with reverse-order delete loop and pdl-10 per-resource node_status
emit — out of deploy.service.ts into a dedicated module. The orchestrator
re-exports destroyDeployment so the namespace import in
routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 644 → 311 LOC.
destroy-deployment.ts: 364 LOC.

* rf-deploy2-5: extract rollbackDeployment to rollback-deployment.ts

Pulls rollbackDeployment (~187 LOC) — the rebuild-from-target-deployment
diff/deploy path — out of deploy.service.ts into a dedicated module.
The orchestrator re-exports rollbackDeployment so the namespace import
in routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 311 → 129 LOC.
rollback-deployment.ts: 210 LOC.

* rf-deploy2-6: orchestrator slim-down — drop unused imports + docstring

Trims now-unused imports from deploy.service.ts (acquireDeployLock,
DeployLockError, providerService, getResourceMap, etc.) and updates the
top-of-file docstring to describe the file's new role: a thin re-export
shim plus 5 small DB-only helpers and the snapshot-persister side-effect
init. Body is unchanged behaviorally.

deploy.service.ts: 129 → 107 LOC (1572 → 107 across the rf-deploy2 series,
93% reduction). Public API surface preserved: every symbol exported
pre-refactor still resolves through the orchestrator (planDeployment,
applyDeployment, destroyAllForCard, destroyDeployment, rollbackDeployment,
getDeploymentStatus, getDeployedResources, getDeploymentHistory,
requestDeployCancel, getCurrentDeploySnapshot, DeployProgressSnapshot,
getNodeDeploymentOverlay, checkDrift).

* rf-deploy2-2 housekeeping: split apply-deployment.ts under 500 LOC

apply-deployment.ts landed at 600 LOC after rf-deploy2-2 (just over the
project's 500 LOC ceiling). This follow-up extracts five mechanically-
separable helpers into apply-pipeline-helpers.ts, bringing the orchestrator
under the ceiling without changing the apply pipeline's behavior:

- logSourceRepoDiagnostics: pre-deploy canvas-shape diagnostic log lines
- ensureAutoDeployRules: best-effort Source.Repository → Compute auto-rule writer
- logDiffForDebugging: console-only desired-vs-current node dump
- normalizeIdempotentResultErrors: NOT_FOUND/ALREADY_EXISTS rewrites
- persistResourceMappings: post-deploy mapping-table writes (in-place mutation)

apply-deployment.ts: 600 → 493 LOC.
apply-pipeline-helpers.ts: 220 LOC.
Adds 11 tests covering the three helper shapes that benefit most from
isolated test coverage.

* rf-deploy2: append awk-line-range learning from rf-deploy2-2

* rf-hlres-1: extract HighLevel* type interfaces to high-level-resources/types.ts

Pulls the 5 type interfaces (ProviderImplementation, HighLevelResource,
OptionDetail, HighLevelProperty, HighLevelCategory) plus the NodeBehavior
re-export out of the orchestrator. The shim now re-exports each name
verbatim, preserving the public API for both packages/core/src/resources/index.ts
and packages/core/src/index.ts.

Smoke test pins:
- the types module is loadable as a namespace
- structurally-typed values for each of the 5 interfaces compile
- the shim re-exports the 5 type names + NodeBehavior
- HIGH_LEVEL_CATEGORIES still has the 7 canonical category ids in order

Baseline typecheck preserved at 29 TS2834 errors (all in unrelated barrel
files); no new errors in touched paths.

* rf-hlres-2: extract compute category to high-level-resources/categories/compute.ts

Cuts the 'compute' object literal (~1779 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES and re-imports it as a const. The orchestrator
shrinks from 6354 to 4577 LOC.

Size exception documented in the file header — the file is dominated by
data (per-resource property catalogues, instance-size pickers, provider
implementations) and would only fragment further without improving
readability.

Smoke test pins the 13 canonical resource ids in order plus the shape
of frontend-app and backend-api. Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-3: extract database category to high-level-resources/categories/database.ts

Cuts the 'database' object literal (~2167 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 4577 to 2411 LOC.

Size exception documented in the file header — same shape as compute.

Smoke test pins the 13 canonical database resource ids in order
(postgres-db through search-engine) plus the shape of postgres-db
and redis-cache. Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-4: extract storage category to high-level-resources/categories/storage.ts

Cuts the 'storage' object literal (~449 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 2411 to 1964 LOC.

Smoke test pins the 5 canonical storage resource ids in order
(object-storage, oss, oci-object-storage, do-spaces, file-storage).
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-5: extract networking category to high-level-resources/categories/networking.ts

Cuts the 'networking' object literal (~559 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1964 to 1407 LOC.

Smoke test pins the 7 canonical networking resource ids in order
(public-endpoint, vpc-network, subnet, load-balancer, cdn, api-gateway,
dns-zone). Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-6: extract messaging category to high-level-resources/categories/messaging.ts

Cuts the 'messaging' object literal (~842 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1407 to 567 LOC.

Smoke test pins the 7 canonical messaging resource ids in order
(message-queue, event-bus, rabbitmq, cloud-pubsub, service-bus,
email-service, event-stream) and that message-queue carries the deep
optionDetails arrays used by the AWS / GCP / Azure queue-type picker.
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-7: extract security + monitoring categories (combined unit)

Cuts the last two object literals (security ~185 LOC, monitoring ~185 LOC)
verbatim out of HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 567
to 200 LOC — 7 categories now imported, no inline literals remain in
HIGH_LEVEL_CATEGORIES.

Both files are well under the 500 LOC ceiling; split out for symmetry
with the rest of the categories sub-tree.

Smoke tests pin the canonical resource ids in order: security
(secret-store, ssl-certificate, service-account) and monitoring
(log-group, alert, dashboard). Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-8: extract helpers + HIGH_LEVEL_CATEGORIES to high-level-resources/helpers.ts

Moves the 7 helper functions (getAllHighLevelResources, palette projection,
filterResourcesByProvider, getBehaviorLabel/Color, getGCPCloudAssetTypes,
cloudAssetToHighLevelType) plus the PULUMI_TO_CLOUD_ASSET map and the
HIGH_LEVEL_CATEGORIES assembly into a new helpers module.

helpers.ts owns the runtime origin of HIGH_LEVEL_CATEGORIES because the
cloud-asset helpers iterate over it — making the helpers the runtime owner
avoids a 'helpers → orchestrator → helpers' cycle. The orchestrator now
re-exports each runtime symbol verbatim so external consumers
(packages/core/src/index.ts, packages/core/src/resources/index.ts,
schema-bridge, property-rules, gcp asset-inventory, type-mapper) keep
the same import surface.

Exhaustive tests pin: HIGH_LEVEL_CATEGORIES order, getAll flatMap +
ordering, palette projection (ice_type / display_name / category /
behavior / providers / implementations / properties), provider filter
+ 'all' bypass + unknown provider returns empty, behavior label/color
delegation, GCP asset-types deduplication, and cloudAsset→HL reverse
lookup including null on unknown.

Orchestrator file: 202 → 37 LOC. Helpers: 195 LOC. Baseline typecheck
preserved at 29 TS2834 errors. Full @ice/core suite (2515 tests) passes.

* rf-hlres-9: orchestrator slim-down — finalize the high-level-resources shim

Polishes the orchestrator after rf-hlres-8 left it as a 37-LOC re-export
shim. Drops the in-progress wording from the docstring, merges the two
`export type {}` blocks into one, and adds section dividers so future
readers can spot the type re-exports vs. the runtime re-exports at a
glance.

Final file size: 47 LOC. Net delta on the rf-hlres series:
  - high-level-resources.ts: 6434 → 47 LOC (a ~99.3% shrink)
  - new files: types.ts, helpers.ts, 7 categories/<name>.ts
  - public API surface unchanged (every name still resolves on the shim)

Repo-wide test run after the slim-down: 8144 tests pass. Baseline
@ice/core typecheck preserved at 29 TS2834 errors (all unrelated).

* rf-hlres: append learning on helpers-owns-assembled-array pattern + awk per-splice tactics

* rf-svgcv2-1: extract pan/zoom transform group as CanvasContent

The inner <g transform=...> body — grid, selection frame, both connection
layers, clipPaths, nodes layer, connection-drawing preview, user-traffic
overlay, and ghost overlay — moves into canvas-renderer/canvas-content.tsx.
Visual draw order, prop flow, and gating logic preserved verbatim.

svg-canvas: 570 → 490 LOC.
canvas-content: 236 LOC (new).

Tests: 10 passing — direct-FC tree-walker matching mocks by reference.
Typecheck clean.

* rf-svgcv2-2: extract SVG mouse-event routing as useCanvasMouseRouting

The four inline arrow handlers on the <svg> element — onMouseDown
(connection-port classList sniff + bindCanvas fall-through), onMouseMove
(drawingConnection branch + bindCanvas fall-through), onMouseUp (same
shape), and onMouseLeave (tooltip dismiss + bindCanvas) — collapse into
a hook that returns the spread-able handler bundle.

Behavior preserved verbatim: classList sniff stays orchestrator-side (per
RISK #5), tooltip dismissal still fires on every onMouseDown/onMouseLeave,
onAuxClick / onContextMenu pass through bindCanvas references unchanged.

svg-canvas: 490 → 473 LOC.
use-canvas-mouse-routing: 114 LOC (new).

Tests: 12 passing — covers all four routing seams + identity-pass
through for AuxClick/ContextMenu. Typecheck clean.

* rf-svgcv2-3: extract selection-dispatch + snapToGrid wiring as useCanvasInteractionsBindings

The orchestrator's useCanvasInteractions call carried three inline
arrow-function callbacks (onSelect / onToggleSelect / onBoxSelect)
plus the snapToGrid → gridSize ternary. Both groups are now bundled in
a thin wrapper hook that calls useCanvasInteractions internally.

Behavior preserved verbatim — the same dispatch sequences fire for each
selection-change branch, and snapToGrid still toggles between GRID_SIZE
and 0. The wrapper drops the snapToGrid prop from the inner args object
so useCanvasInteractions sees only its own keys.

svg-canvas: 473 → 457 LOC.
use-canvas-interactions-bindings: 69 LOC (new).

Tests: 8 passing — covers each dispatch callback, the gridSize ternary,
and the snapToGrid prop-drop. Typecheck clean. Full canvas-tests suite:
207/207 passing.

* rf-svgcv2-4: extract RenderCtx assembly as useRenderCtx hook

The eighteen-field renderCtx object the per-node renderer dispatch
consumes — sortedNodes, lod, pipelineNodeStatus, drag/exit highlight
ids, the seven event-handler callbacks, etc. — moves into a
useRenderCtx hook. The hook also binds getConnectedPipelineStatuses
to (card, pipelineNodeStatus) so renderers call it as a single-arg
function (preserving rf-canv2-5's surface).

Behavior preserved verbatim: the returned ctx contains every field
the original literal had, with the same refs. card and zoom move
into the hook's args bundle; svg-canvas no longer imports
getConnectedPipelineStatuses or the RenderCtx type.

svg-canvas: 457 → 453 LOC.
use-render-ctx: 105 LOC (new).

Tests: 5 passing. Bonus: rf-svgcv2-3 test JSX-form fix (Provider needs
explicit children prop in TS strict — convert createElement(Provider,
{store}, child) to JSX form). Typecheck clean.

* rf-spr2-1: split system prompt into composable section builders

The 350-line static prose body of buildSystemPrompt's template literal
moves into seven section-builder functions in system-prompt-sections.ts:
  - buildHeaderPrompt(dominantProvider)
  - buildIntentRoutingPrompt() — WHEN TO ACT vs WHEN TO ASK
  - buildOperationsPrompt(availableBlockTypes) — STRICT BLOCK REGISTRY
  - buildPropertyPrefillPrompt() — PROPERTY PRE-FILL RULES
  - buildOptimizationGuidelinesPrompt() — improve security/cost/HA/...
  - buildCanvasContextPrompt(nodes/edges/selected/schema) — Current Canvas
  - buildContainerNetworkingPrompt(connectionPrompt) — VPC + WIRING

The orchestrator concatenates them via the + operator. Output is
byte-identical to the pre-rf-spr2 prompt — verified by a new snapshot
test (3 fixtures: bare canvas, cloud-architect skill, question intent
with deployment context). Pure helpers (formatNodesSummary,
detectDominantProvider, buildCloudArchitectPrompt) stay in
system-prompt.ts.

system-prompt.ts: 516 → 203 LOC.
system-prompt-sections.ts: 420 LOC (new, prose-heavy — header documents
the data-table exception).

Tests: 33 passing (30 existing + 3 new snapshot fixtures).
Typecheck clean.

* rf-asttyp-1: split AST types by category, keep ast/types.ts as re-export shim

The 581-LOC types.ts split into four sub-files by AST node category:
  - types/base.ts        — AstNode + AstNodeKind union (65 LOC)
  - types/expressions.ts — Expression + literals + access + splat +
                            for-comprehension types (253 LOC)
  - types/blocks.ts      — Block + Attribute + NestedBlock (44 LOC)
  - types/statements.ts  — Program + Statement union + every top-level
                            block + LifecycleConfig + ValidationRule +
                            TypeExpression (268 LOC)

ast/types.ts becomes a 70-LOC type-only re-export shim. Every existing
consumer (the parent ast.ts shim, ast-helpers.test.ts, plus the broader
parser/visit_ast machinery) keeps importing from '../ast/types.js'
unchanged.

Tests: 15 type-shape tests added in __tests__/types-shim.test.ts —
covers every kind discriminator + the optional-field shapes for
ResourceBlock/LifecycleConfig/TypeExpression. Full parser test suite:
297/297 passing. Typecheck: no new errors (existing 29 TS2834 baseline
errors unaffected per the ts2834-baseline learning).

* rf-svgcv2-4 followup: fix RenderCtx → Record cast in test (TS2352)

Two-step cast (as unknown as Record) to satisfy strict overlap check —
RenderCtx has no index signature so the direct cast fails.

* rf-svgcv2 + rf-spr2-1 + rf-asttyp-1: capture three new learnings

- byte-identity-snapshot-must-be-captured-pre-refactor-not-post (rf-spr2-1)
- svg-canvas-orchestrator-loc-budget-flexible-with-renderctx-bundling (rf-svgcv2-4)
- render-ctx-record-cast-needs-double-as-unknown-as-record (rf-svgcv2-4)

* rf-spdat: split scale-presets-data 1482 LOC into 7 category files + 46-LOC orchestrator

Extract SCALE_PRESETS dict by domain category (compute, database, storage,
networking, messaging, security, monitoring) under scale-presets-data/.
Orchestrator becomes a 46-LOC re-export shim that spreads each per-category
record into the SCALE_PRESETS dict — order preserved.

Per-category sizes (LOC): compute 387, database 500, messaging 265,
networking 166, storage 142, security 30, monitoring 30. All categories
data-only (size-exception applies).

Smoke test (scale-presets-data.test.ts): 14 tests pinning category counts,
no cross-category collisions, assembled-dict equals union, byte-identical
sample lookups for compute/database/monitoring tiers.

* rf-cbdat: split cloud-blocks-data 1009 LOC into 9 category files + 126-LOC orchestrator

Extract the 16 BlockTemplate entries by their declared `category:` field
under cloud-blocks-data/ (frontend, backend, compute, data, storage,
networking, messaging, observability, security). Orchestrator assembles
BLOCK_TEMPLATES in the documented original order and derives BLOCK_CATEGORIES
(palette grouping) by category-field filter.

Per-category sizes (LOC): backend 210, data 192, networking 128, messaging
106, security 106, frontend 79, storage 72, compute 70, observability 65.

Smoke test (cloud-blocks-data.test.ts): 17 tests pinning per-category
counts, no cross-category template-name collisions, byte-stable assembled
ordering vs. the original file, BLOCK_CATEGORIES references the assembled
list (filter, not clone).

* rf-thmdat: split themes 590 LOC into 3 4-theme groups + 30-LOC orchestrator

Extract the 12 ColorTheme entries into 3 group files (4 themes each) by
the original ordering: group-1 default/retro/cupcake/valentine (warm),
group-2 synthwave/coffee/luxury/aqua (bold), group-3 forest/sage/dracula/
night (nature/dark). Orchestrator becomes a 30-LOC re-export shim that
spreads the three groups into the public T array.

Per-group sizes: 200/201/201 LOC — each well within the 200-500 ceiling.

Smoke test (themes-data.test.ts): 12 tests pinning per-group counts and
ordering, no cross-group id collisions, byte-stable assembled T array,
reference-equality preserved through the spread (orchestrator FC depends
on T[i] identity for the shallow click-handler closures).

* Final round complete: all exceptions decomposed (excluding generated files)

8 exception files split: deploy.service, high-level-resources, svg-canvas,
system-prompt, ast/types, scale-presets-data, cloud-blocks-data, themes.

12,754 → 1,082 LOC across orchestrators (-91%). 28 commits, +180 tests.

Files over 500 LOC remaining: only the generated resource-types.ts
(excluded per user) and 4 high-level-resources category sub-modules
(pure resource-definition data with SIZE EXCEPTION headers; further
fragmentation would split related resources without maintainability
gain).

* bugfix-2: lazy require.resolve in get_base_db_path

The pre-fix code eagerly evaluated
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` while
constructing the candidate array, so the function threw before
reaching the existsSync loop in any environment where that package
isn't installed (test envs, fresh checkouts, this monorepo's dev
setup). Wrap each candidate in a thunk and the require.resolve
fallback in its own try/catch so a missing package degrades to
"skip this candidate" instead of crashing the whole call.

Tests:
- Confirm get_base_db_path() does not throw without the package.
- Confirm it returns a string ending in ice-schemas.db.
- Confirm it returns the dev path when staged at the resolved location
  (priority over the missing-package fallback).

Discovered during rf-cload-3 (extraction of `get_base_db_path` from
`customization-loader.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* bugfix-3: get_critical_path now propagates distance through chain

Pre-fix the distance update walked `get_incoming_edges` and read
the *source's* distance, but `topological_sort` emits leaves first
for `depends_on` graphs — so source nodes are processed AFTER the
current node and their distance is always -Infinity at lookup
time. The chain never propagated past the start node and the
function returned `[start]` for any DAG.

Fix: walk `get_outgoing_edges` (the current node's dependencies)
and read the *target's* distance. Targets are processed earlier
in topo order (leaves first), so the lookup is always populated.

Tests:
- 3-node chain a→b→c → returns [c, b, a] (was [c]).
- 4-node chain a→b→c→d → returns [d, c, b, a].
- Diamond DAG (a→b, a→c, b→d, c→d) → 3-node longest path.
- Isolated node → returns [solo].
- Empty graph → [].
- Cyclic graph → [].
- Disconnected components → returns longest among them.
- Two pre-existing softened tests (`expect(path.length)
  .toBeGreaterThanOrEqual(1)`) removed; replaced by strict
  assertions that pin the corrected length and node order.

Path orientation: leaf-first → root-last, matching the natural
"start → end" reading of a critical path. For chain a→b→c the
"start" is c (no deps) and the "end" is a (most-dependent).

Discovered during rf-galg-4 (extraction from `graph/algorithms.ts`);
preserved verbatim then per the verbatim-preservation rule for
refactors. Now fixed as a behaviour-change PR per the rule that
pre-existing bugs documented in learnings get separate fix
tickets.

* bugfix-4: detectJsFramework now reads pnpm/yarn lockfile signals

Pre-fix `filesToCheck` had only 6 entries — `package.json`,
`Dockerfile`, `requirements.txt`, `go.mod`, `pom.xml`, `Cargo.toml`.
The detector iterated these and pushed found names into
`detectedFiles`, but the JS-ecosystem branch in `detectJsFramework`
checked `detectedFiles.includes('pnpm-lock.yaml')` /
`yarn.lock` — neither of which was in `filesToCheck`. So the
package-manager guess always fell through to npm regardless of
which lockfile the repo actually had.

Fix: add the three JS lockfiles to `filesToCheck` so they show up
in `detectedFiles` when present. Three extra Contents-API GETs per
detection call; cheap given GitHub's rate limits and one-shot
nature of this path.

Tests:
- pnpm-lock.yaml present → packageManager='pnpm',
  installCommand='pnpm install --frozen-lockfile'.
- yarn.lock present → packageManager='yarn',
  installCommand='yarn install --frozen-lockfile'.
- package-lock.json present → packageManager='npm' (default fall-
  through preserved; ladder has no explicit branch for it).
- No lockfile → packageManager='npm' (still works).
- pnpm + yarn both present → pnpm wins (ladder priority pinned).
- Removed the test that pinned the broken behavior ("package.json
  defaults to npm package manager (lock files are not in
  detectedFiles list)") — replaced by the corrected
  pnpm/yarn/no-lockfile triplet.

Bundles the SHA fixup for the bugfix-3 `_Fixed:_` line in
state/learnings.md.

Discovered during rf-pipe-6 (extraction of `framework-detection.ts`
from `pipeline.service.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* learnings: append bugfix-2/3/4 sweep gotcha (Fixed-line audit trail)

Capture the rule of thumb that emerged from the bugfix sweep: only
the rf-galg-4 quirk had a dedicated learning anchor; the other two
bugs were documented inline in their SUT header comments. The
brief assumed every bug had its own anchor for the `_Fixed:_`
audit trail; reality is sparser and the implementer should pick
between inline-doc and learning-anchor based on whether the
deferred fix is a real future ticket.

* progress: archive entry for completed LOC discipline initiative

Multi-day refactor work (2026-04-29 → 2026-05-02) marked complete in
the Archive section of progress.md. Cross-references to phase totals,
bug fixes, and the new /docs/refactoring-patterns.md doc.

Cumulative: ~470 commits, ~7500 new tests, 73 files refactored,
4 latent bugs fixed, 1 generated file excluded, 4 data-leaf
exceptions remaining (documented).

Codebase decomposition is complete. Pausing here.

* progress: housekeep In flight, move pdl + rf-* detail trail to Archive

Reduces progress.md from 322 → 216 lines. In flight section now contains
just the 6 deferred follow-ups (pdl-11, rollupPercentage, nodesById warm-
seed, dead snapshot fields, data.status fallback, 3 rf-0c dedups). All
pdl-1..10 and rf-deploy/rf-props/rf-canv/rf-pdpl/rf-ctrans/rf-cards/
rf-fbh/rf-parse subsections moved verbatim under Archive so anchor/commit
references remain searchable.

* decisions: 2026-05-02 merge-story entry for refactoring branch

Document the choice to merge the 509-commit refactoring branch as a
single PR rather than splitting per phase. Rationale and alternatives
captured in the decision entry; consequences note that deferred follow-
ups (pdl-11 etc.) ship as separate PRs after the merge.

* fix(test): cast source_node_id read in apply-pipeline-helpers test

The helper mutates result.resources[i].source_node_id at runtime via
res: any. The test fixture's inline literal infers a strict type
without source_node_id, so the post-call assertion fails TS2834.
Add { source_node_id?: string } cast at the two assertion sites to
match the runtime mutation contract.

* state: archive pre-compaction snapshot of learnings.md (2026-Q2)

Verbatim copy of learnings.md before the Q2-2026 compaction pass.
Per CLAUDE.md schedule: cluster duplicates once per quarter, archive
the pre-compaction file as state/archive/learnings-YYYY-Qn.md, then
write the compacted version back. The compacted version lands in the
next commit.

204 anchors / 1676 lines preserved here for reference.

* state: compact learnings.md (Q2-2026 pass)

Cluster duplicates and trim redundancy after the parallel-deploy +
LOC-discipline initiatives. 204 → 113 anchors (-44%), 1676 → 780
lines (-53%). All 25 must-preserve anchors retained verbatim (the 24
referenced from /docs/refactoring-patterns.md plus the read-state-first
anchor cited in decisions.md and CLAUDE.md). Both _Promoted to:_ and
_Fixed:_ trailers preserved verbatim.

Representative cluster merges (canonical ← folded):
- ux-log-terminal-pitfalls ← 5 ux-log-* siblings
- ux-deploy-real-cloud-pitfalls ← 3 ux-deploy-* siblings
- pdl-7-wire-contract-trims-downstream-ui ← 3 pdl-7 follow-ups
- pdl-10-destroy-snapshot-and-dedup-traps ← 3 pdl-10 follow-ups
- ux-pdl-smoke-test-pitfalls ← 3 ux-pdl-* siblings
- inline-classification-duplications-are-not-actually-duplicates
  ← 4 sibling rf-canv anchors about inverted tie-breaks
- test-helper-defaults-traps-coalesce-and-spread ← 3 helper-default traps
- brief-numerics-are-approximate-source-is-canonical ← absorbed 3 brief-vs-source variants

Pre-compaction snapshot archived in the prior commit at
state/archive/learnings-2026-Q2.md.

* pdl-11: default node.data.provider to active deploy provider on drop

Closes the deferred follow-up from the parallel-deploy initiative
recorded in state/progress.md In flight. Palette drops without an
explicit `application/ice-block-provider` key (or with the 'all'
sentinel) now fall back to `state.deploy.provider`, so the deploy
panel doesn't filter newly-dropped blocks as "skipped — non-<provider>"
when the toolbar provider is set.

Three drop branches:
- Group drop: unchanged (groups have no provider).
- Block drop: paletteProvider 'all' or unset → effectiveProvider
  becomes deployProvider; threaded into both getBlueprint and
  expandBlueprint. Explicit palette providers (aws/azure) still win.
- Resource drop: newNodeData.provider is set to deployProvider.

logBlueprint still records the *palette* provider (analytics tracks
user intent, not the post-fallback value).

Tests: 21 → 23 in use-canvas-drop.test.tsx (+ 2 pdl-11 cases:
fallback-when-palette-omits, palette-wins-over-deploy-provider). One
existing test assertion updated from `provider: 'all'` to `provider:
'gcp'` to match the new behavior. Resource-drop case asserts
`data.provider === 'gcp'` (default deploy provider in the test store).

* deploy: extract deriveRollupPercentage helper from 3 inline copies

Closes pdl-5 critic findings #2 and #4 (deferred to follow-up). The
cap-at-99 progress formula was inlined identically in three places:
  - features/deploy/components/deploy-in-flight-panel.tsx:50
  - features/canvas/components/deploy-banner.tsx:94
  - shared/components/status-bar.tsx:216

Extracted to deploy/derive.ts next to deriveRollup as a pure projection
from DeployRollup → 0..100 percentage. Re-exported from deploy-slice
alongside deriveRollup / orderNodesForPanel so existing import paths
keep resolving.

Tests: 5 new dedicated cases for deriveRollupPercentage (empty / full /
cap-at-99 boundary / rounding / defensive zero-total). The
deploy-in-flight-panel.test mock switched to importOriginal +
selective override so deriveRollupPercentage runs real (it's a pure
projection from the rollup the test already controls via
mocks.deriveRollup) — saves maintaining a parallel mock that has to
mirror the cap formula.

19 → 19 derive tests (+5), 53 → 53 deploy-in-flight tests (refactor
preserves behavior), 0 typecheck regressions.

* deploy: warm-seed nodesById from snapshot in Phase 2 (pdl-5 #7)

Closes pdl-5 critic finding #7 (deferred to follow-up). When a tab
joins mid-deploy, Phase 2 of useDeploySubscription pulls the persisted
snapshot from the gateway and was previously only mirroring the
overlay onto the canvas blocks. The deploy panel's per-row list,
which derives from `nodesById`, stayed empty until Phase 2.5's replay
loop completed — leaving a brief "Preparing…" sentinel window even
though the per-block overlays already showed the right colors.

The warm-seed dispatches synthetic `node_status` events (and
`node_progress` when the snapshot carries a step descriptor) for each
node in `snapshot.nodeStatuses`, so the panel's per-row list renders
immediately.

Synthetic events use seq=0 so any live event (or replayed event from
Phase 2.5) with seq>0 dedup-wins on the same node, overwriting the
warm-seed entry's empty resource_name / resource_type / action with
the authoritative wire values.

New helper: overlayToWireStatus inverts mapWireStatusToOverlay
(snapshot stores the post-mapping overlay string; warm-seed walks it
back to a wire DeployNodeStatus). Returns null for overlay strings
that don't round-trip — e.g. 'destroying' / 'gone' from pre-pdl-10
destroy paths — so warm-seed skips them and Phase 2.5 fills them in
from the event tape.

Tests: 14 → 17 in use-deploy-subscription.test.ts (+3 for
overlayToWireStatus: full inverse map, null fallback for unknown
strings, round-trip through mapWireStatusToOverlay).

* deploy: drop DeployProgressSnapshot dead fields

The frontend stopped reading snapshot.progress, snapshot.currentResource,
and snapshot.currentStep when pdl-5 rewired the deploy panel + canvas
banner to derive every in-flight signal from `nodesById` (populated by
the typed node_status / node_progress wire). The server-side writers
have been writing dead state ever since.

Removed:
- progress: number, currentResource?, currentStep? from
  DeployProgressSnapshot interface (deploy-locks.ts).
- progress: 0 seed in startDeploySnapshot.
- progress: 100 stamp in finishDeploySnapshot.
- updateDeploySnapshot helper (no remaining callers after this drop).
- progress / currentResource writes in scheduler-callbacks (the totals
  count bump is preserved — callers still read it after the deploy
  returns).
- progress: 0 in canvas-deploy.ts /current/:cardId fallback.

The Json column on the Prisma `canvas_deployment.snapshot` field is
schemaless, so removing keys from the TS type doesn't migrate. Old DB
rows still carry these fields; new rows won't. The shared-modules
registry entry for the deploy-locks export set was trimmed to match.

Tests: 32 → 30 in scheduler-callbacks (caps-at-99 + write-only-
currentResource cases were specifically about the dead-field semantics
and dropped; the count-bump-on-terminal and per-node-mirror cases
preserved).

* ui: drop data.status legacy fallback per one-status-source-deploy-status

The compact-node deploy-pill render path read
`(data.deploy_status as string) || (data.status as string) || ''`,
making `data.status` a de-facto status source no matter how careful
the writer side stayed. Per the `one-status-source-deploy-status`
learning, removing the OR makes `deploy_status` the single source of
truth.

Coordinated with the writer-side sweep so no node-creation site seeds
`status: 'active'` anymore — the field stays empty until the deploy
pipeline writes `deploy_status`.

Sites cleaned (writer side):
- packages/blocks/src/{aws,azure,gcp}/security/waf.ts (3 WAF blueprints)
- packages/blocks/src/expand-blueprint.ts (mergedData seed)
- packages/templates/src/expand-template.ts (group + fallback resource)
- packages/ui/src/features/canvas/hooks/use-canvas-drop.ts (group + resource branches)
- packages/ui/src/store/slices/cards/reducers/undo-redo-group.ts
  (groupSelectedNodes new container)

Drift-checker writes (use-drift-check.ts) re-routed from `status:
'drifted' | 'active'` to `deploy_status:`-prefixed equivalents — drift
state IS a deploy outcome, not a separate field.

Reader side: compact-node/index.tsx:81 fallback dropped + comment
rewritten to point at the learning anchor for context.

Tests updated: use-canvas-drop (group + resource asserts), use-drift-
check (3 dispatched-payload asserts, both 'drifted' and 'active'),
undo-redo-group (drop the bounding-box test's status assertion).
8261 unit tests passing across the workspace; 0 typecheck regressions.

* rf-0c: hoist mapStatusToOverlay + overlayToWireStatus into @ice/types

The wire-status → canvas-overlay mapping had three redundant homes:
  - services/deploy/src/utils/deploy-event-formatter.ts:mapStatusToOverlay
  - packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts
    :mapWireStatusToOverlay (+ overlayToWireStatus from 4c)

Both were kept in sync by hand and a long docstring warning
"Both sides must pick the same overlay string for the same wire status."
Drift between the two would mean a tab opened mid-deploy hydrates a
node with one color via the snapshot path and gets the same node
overwritten to a different color by the live event microseconds later.

Canonical home is now packages/types/src/deploy-events.ts next to
DeployNodeStatus. Service-side and UI-side modules re-export to
preserve consumer imports:
  - deploy-event-formatter.ts re-exports mapStatusToOverlay
  - use-deploy-subscription.ts re-exports
    `mapStatusToOverlay as mapWireStatusToOverlay` and overlayToWireStatus
    so…
julia-kafarska added a commit that referenced this pull request May 20, 2026
* rf-cload-1: extract example-file content + paths from customization-loader

Pulls the four `_example.<ext>.disabled` content blocks (provider JSON,
override YAML, custom resource YAML, relationships YAML) out of the
inline `create_example_files` private method into named constants in
schema/customization/example-files.ts. The CustomizationPaths type and
subdir constants move to schema/customization/paths.ts.

Customization-loader.ts re-exports CustomizationPaths from the new
location so external consumers' import paths are unchanged. Behaviour
preserved: each file only created when missing, content byte-identical.
9 new tests pin both the constants and the directory write-out.

customization-loader.ts: 521 -> 410 LOC.

* rf-cload-2: extract per-file validators from customization-loader

Pulls the four `validate_*_file` private methods (provider JSON, override
YAML, custom resource YAML, relationships YAML) into
schema/customization/file-validators.ts as standalone async functions,
each returning `FileValidationResult`. The CustomizationError and
ValidationWarning types move to that file too — the orchestrator
re-exports them so external consumers (schema/index.ts) keep their
import paths.

Behaviour preserved verbatim: "Invalid JSON: ..." / "Invalid YAML: ..."
prefixes on parse failures, 1-indexed relationship error messages,
provider resources without properties emit warnings (not errors). 16
new tests drive each helper end-to-end via tmp file writes.

customization-loader.ts: 410 -> 261 LOC.

* rf-cload-3: extract directory scanner + base-db resolver

Pulls the `scan_directory` private method into
schema/customization/scanner.ts (along with the CustomizationFile type
that the scanner produces) and the `get_base_db_path` standalone
function into schema/customization/base-db.ts. The orchestrator
re-exports CustomizationFile + delegates `get_base_db_path` so external
imports are unaffected.

Behaviour preserved verbatim including the pre-existing buggy
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` eagerness
(noted in the test file's docstring; not in scope to fix here). 7 new
tests for the scanner driving real fs reads via tmp dirs.

customization-loader.ts: 261 -> 204 LOC. File 3 series complete.

* state: add three learnings from P3 cohort 3 (rf-esp / rf-rval / rf-cload)

- ts2834-baseline-error-moves-with-the-import: extracting a TS2834
  baseline import into a deeper subdirectory relocates the error,
  doesn't introduce one.
- fs-existssync-is-non-configurable-under-vitest-esm: vi.spyOn fails
  on node:fs namespace imports; use chdir into tmpdir instead.
- one-source-of-truth-for-types-in-shim-refactors: when decomposing
  a class file, public types live in a sibling <name>-types.ts to
  avoid cycles between the shim and helper modules.

* rf-pimp-1: extract state parsing helpers from pulumi state-importer

Pulls six pure helpers out of state-importer.ts into a new
parsing.ts module:

  - get_deployment           — read PulumiDeployment from either state shape
  - get_stack_info           — derive {stack,project} from checkpoint or URN
  - extract_name_from_urn    — last-segment fallback when parse_urn fails
  - is_secret_value          — sentinel-UUID check for Pulumi secret wrappers
  - unwrap_secret            — ciphertext > plaintext > value resolver
  - create_empty_metadata    — unknown-sentinel PulumiImportMetadata for errors

state-importer.ts: 564 -> 481 LOC.

22 new tests in parsing.test.ts pin the byte-identical behaviour
(secret sentinel UUID, both state shapes, URN fallback path,
empty-metadata defaults). All 79 existing pulumi/terraform tests
still pass; TS2834 baseline (29) unchanged.

* rf-pimp-2: extract resource conversion from pulumi state-importer

Pulls the resource-shape conversion out of state-importer.ts into a
new resource-conversion.ts module:

  - process_properties — recursive property walker with secret unwrap
  - import_resource    — PulumiResource -> PulumiImportedResource

Both are pure (warnings array is mutated like before).  Behaviour
preserved verbatim:
  - URN parse + extract_name_from_urn fallback for name
  - outputs > inputs precedence (with NO_OUTPUTS warning fallback)
  - dependencies = explicit ++ parent (parent appended last, in order)
  - additional_secret_outputs mirrored verbatim
  - protect/external default false; id passes through

state-importer.ts: 481 -> 387 LOC.

18 new tests pin the secret-mask path, the warning emission path, the
parent-last dependency append, and the protect/external defaults.
86 total importer tests pass; TS2834 baseline (29) unchanged.

* rf-pimp-3: extract graph conversion from pulumi state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph — PulumiImportResult -> MutableGraph
  - import_pulumi_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer and
the existing 79 importer tests bind directly to it.

state-importer.ts: 387 -> 278 LOC (564 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/stack/project)
  - per-node labels (provider, pulumi_type, optional protected/external)
  - per-node provenance annotations (imported_from, pulumi_urn)
  - resource.id lifted to node properties.id
  - self-dependency and missing-target edges skipped
  - target_graph merge mode preserves nodes only (edges already dropped)

12 new tests cover the node properties/labels/annotations, the edge
filters (self-loop + missing target), and the graph metadata.  98
total importer tests pass; TS2834 baseline (29) unchanged.

* rf-timp-1: extract sensitive-attribute masking + empty metadata

Pulls three pure helpers out of state-importer.ts into a new
sensitive.ts module:

  - mask_sensitive_attributes — top-level walker over sensitive paths
  - mask_path                 — recursive leaf-mask mutator
  - create_empty_metadata     — unknown-sentinel ImportMetadata for errors

state-importer.ts: 547 -> 514 LOC.

15 new tests pin the path-tokenisation behaviour:
  - top-level / dotted / bracket-array path forms
  - non-object intermediates abort the walk (no error)
  - missing leaves and null intermediates are no-ops
  - empty path / empty-input early return

All 79 existing pulumi/terraform tests still pass; TS2834 baseline
(29) unchanged.

* rf-timp-2: extract resource conversion + dependency inference

Pulls three functions out of state-importer.ts into a new
resource-conversion.ts module:

  - import_resource_instance — (resource, instance) -> ImportedResource
  - infer_dependencies       — id/arn-driven dep inference post-pass
  - scan_for_references      — recursive walker over property tree

state-importer.ts: 496 -> 368 LOC.

Behaviour preserved verbatim:
  - address = [module.]type.name[index_key], JSON-encoded index_key
  - ICE name = name_prefix + name + (_index_key when present)
  - sensitive_attributes path masking + SENSITIVE_MASKED warning
  - explicit instance.dependencies pass-through
  - id_lookup indexed by both 'id' AND 'arn' property values
  - Set-seeded dedup union of explicit + inferred deps
  - dependencies[] mutated in place per resource

22 new tests cover address building (incl. module/index_key paths),
the masking warning emission, the include_sensitive=true bypass, and
all three sub-cases of scan_for_references (string/array/object/null).
65 total terraform tests pass; TS2834 baseline (29) unchanged.

* rf-timp-3: extract graph conversion from terraform state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph    — TerraformImportResult -> MutableGraph
  - import_terraform_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer.

state-importer.ts: 368 -> 268 LOC (547 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/lineage)
  - per-node labels (provider, terraform_type, optional module)
  - per-node provenance annotations (imported_from, terraform_address)
  - all edges tagged 'inferred: true' regardless of origin
  - missing-target edges silently skipped
  - target_graph merge mode preserves nodes only

10 new tests cover the node properties/labels/annotations, the
optional module label, and the missing-target edge filter. 75 total
terraform importer tests pass; TS2834 baseline (29) unchanged.

* rf-aimp-1: extract ARN/tag helpers from aws-importer

Pulls four pure helpers out of aws-importer.ts into a new
arn-helpers.ts module:

  - extract_name_from_arn   — trailing /-or-:-separated name
  - extract_account_from_arn — 5th segment, '' when malformed
  - extract_region_from_arn  — 4th segment, 'global' default for IAM/CF
  - parse_tags               — Tags-array OR tags-object normalisation

aws-importer.ts: 533 -> 495 LOC.

Behaviour preserved verbatim:
  - 6-segment ARN check, segment-5+ join for resource portion
  - resource fallback when split-on-/-or-: leaves empty trailing
  - global default for empty region slot (IAM, CloudFront)
  - Tags array preferred over tags object when both exist
  - String() coercion for non-string Key/Value pairs

22 new tests cover all four helpers across well-formed ARNs,
malformed inputs, IAM-style global resources, and both tag formats.
TS2834 baseline (29) unchanged.

* rf-aimp-2: extract AWS SDK init from aws-importer

Pulls the dynamic-import wrapper functions out of aws-importer.ts into
a new sdk-init.ts module:

  - AWSSdk interface  — STS / ResourceExplorer / ConfigService bundle
  - init_aws_sdk      — dynamic-import client-sts/-resource-explorer-2/
                        -config-service, optional fromIni({profile})
  - get_account_id    — STS GetCallerIdentity wrapper, 'unknown' on err

aws-importer.ts: 495 -> 444 LOC.

Behaviour preserved verbatim — including the load-bearing
'Function("m", "return import(m)")' pattern that prevents bundlers
from transpiling the dynamic import to a static require (which would
break the optional-dep guarantee for users who never use AWS).

5 new tests cover the friendly install-the-sdk error path and the
'unknown' fallback in get_account_id.  TS2834 baseline (29) unchanged.

* rf-aimp-3: extract resource discovery from aws-importer

Pulls the two paginated AWS-API discovery loops out of aws-importer.ts
into a new discovery.ts module:

  - map_resource_explorer_hit       — pure: hit -> AWSResource
  - map_config_result               — pure: JSON-string -> AWSResource|null
  - discover_with_resource_explorer — paginated SearchCommand wrapper
  - discover_with_config            — paginated SelectResourceConfig wrapper

Two pure mappers were extracted alongside the discover_*() loops to
make the response-shape -> AWSResource conversion testable without
needing to stub the dynamic @aws-sdk/client-* imports (the
'Function("m", "return import(m)")' indirection bypasses any
Vitest module registry).

aws-importer.ts: 438 -> 358 LOC.

Behaviour preserved verbatim:
  - Resource Explorer: QueryString='*', MaxResults=100, NextToken pagination
  - Config: SelectResourceConfigCommand SQL DSL (LIKE '%')
  - region default 'global' for both paths
  - resourceId preferred over ARN-derived name (Config only)
  - JSON.parse failures silently skipped (Config only)

8 new tests cover the pure mappers across well-formed / partial /
malformed inputs, plus the SDK-not-installed failure path on each
discover_*() entrypoint.  TS2834 baseline (29) unchanged.

* rf-aimp-4: extract graph conversion + relationship inference

Pulls the two graph functions out of aws-importer.ts into a new
graph-conversion.ts module:

  - aws_result_to_graph  — AWSImportResult -> MutableGraph
  - infer_relationships  — ARN-driven dep inference post-pass

aws-importer.ts re-exports aws_result_to_graph via barrel-style
'export {...} from' to preserve the public surface — index.ts and
the importers index both bind to it.  Local consumer
import_aws_to_graph aliases the local import as aws_result_to_graph_impl
to avoid same-name collision with the re-export.

aws-importer.ts: 346 -> 250 LOC (533 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source, account_id)
  - per-node labels: provider, aws_type, account_id, region, ...tags
    (tags spread last — tags WIN on key collision with canonical labels)
  - per-node provenance: imported_from, aws_arn, aws_account
  - depends_on edges with inferred:true + source:aws labels
  - self-dependency and missing-target edges silently skipped
  - infer_relationships REPLACES dependencies (not unions) — load-bearing
  - ARN matching gated by 'arn:aws:' prefix and arn_set membership

19 new tests cover the canonical-label-vs-tag collision, the
self-dep/missing-target edge filters, dedup of repeated references,
own-ARN exclusion, and the dependency-replacement (not -union)
contract.  148 total importer tests pass; TS2834 baseline (29)
unchanged.

* state: add two learnings from P3 cohort 4 (rf-aimp series)

dynamic-import-indirection-blocks-test-mocks
  The Function('m', 'return import(m)') pattern in aws-importer
  bypasses Vitest's module registry — vi.mock can't stub the AWS
  SDK calls. Workaround: extract pure mappers and test those.

same-name-local-import-and-reexport-collision
  When an extracted function is both consumed locally AND re-exported
  for the public surface, alias the local import to make the two
  roles explicit (X_impl vs X).

* rf-tfexp-1: extract terraform types module

Verbatim port of TerraformExportOptions / RequiredProvider /
TerraformResource / TerraformLifecycle / TerraformConfig /
TerraformBlock / TerraformProviderConfig / TerraformVariable /
TerraformOutput / TerraformExportResult shapes from
terraform-exporter.ts (pre-extraction L20-160) into a dedicated
types module. Public surface preserved — re-exported from the
orchestrator in the slim-down unit.

* rf-tfexp-2: extract terraform sanitize_name helper

Verbatim port of the private sanitizeName method from
terraform-exporter.ts (pre-extraction L420-428) into a pure
helper module. The Terraform identifier rules differ from
Pulumi's (underscore prefix vs r- prefix; preserves _ in
identifiers); kept separate to avoid coupling.

Tests: 10 cases covering alphanumeric pass-through, dot/slash/
space substitution, leading-digit prefix, unicode replacement.

* rf-tfexp-3: extract terraform fallback_type_mapping helper

Verbatim port of the private fallbackTypeMapping method from
terraform-exporter.ts (pre-extraction L335-362). Provider-prefix
table preserved exactly; gcp/aws/azure branch ordering preserved
(a gcp.* type always hits the gcp branch even if provider token
is non-gcp). Documented the pre-extraction quirks where the aws
and azure branches hard-code the prefix regardless of provider
token.

Tests: 17 cases covering each branch + provider mapping table.

* rf-tfexp-4: extract terraform value-transform helpers

Verbatim port of mapProperties / transformValue / formatDependencies
from terraform-exporter.ts (pre-extraction L367-418). Three pure
transformations with no class-state dependency.

Key differences vs Pulumi's value-transform: keys are preserved
AS-IS (Terraform uses snake_case natively); transform_value does
not rename nested keys. format_dependencies emits # placeholders
(pre-extraction had a TODO comment about lookup; preserved).

Tests: 23 cases covering null/undefined normalisation, _-prefix
filtering at top level only, recursive nested transforms, array
handling, dependency placeholder formatting.

* rf-tfexp-5: extract terraform HCL formatter helpers

Verbatim port of formatHCLValue / toHCL / toJSON from
terraform-exporter.ts (pre-extraction L433-545). Pure functions;
no class state.

The HCL output format is byte-identical to the pre-extraction
class methods. Particularly load-bearing:
 - String escape order (backslash first, then quote)
 - null/undefined property values SKIPPED (not emitted as null)
 - depends_on block omitted when empty
 - HCL object syntax 'key = value' (not JSON-style 'key: value')
 - Trailing blank line after each section

Tests: 37 cases covering all value types + full to_hcl snapshot
regression guard.

* rf-tfexp-6: extract terraform converter helpers

Verbatim port of buildDependencyMap / nodeToResource /
exportGraph from terraform-exporter.ts (pre-extraction L189-330).
The class state previously held by the orchestrator
(schema_provider) is now passed as the first argument to each
helper.

Pre-extraction quirks preserved:
 - depends_on edge filter (other relationships ignored)
 - node.properties || {} defensive default
 - unmapped_types deduped via Set; warnings NOT deduped
 - format-selection branch checks 'json' literal; everything
   else (including undefined) emits HCL

* rf-tfexp-7: slim terraform-exporter orchestrator (558 -> 102 LOC)

The class is now a thin orchestration shell:
 - constructor instantiates schema_provider
 - initialize() lazy-initialises the schema provider
 - exportGraph() delegates to ./terraform/converter.export_graph

Public API unchanged — TerraformExporter, create_terraform_exporter,
and the eleven exported types all keep their pre-extraction shape.
External consumers (export/index.ts) continue importing through
the orchestrator path; the new terraform/* modules are internal.

* rf-pmap-1: extract pulumi type-mapper data tables

Verbatim port of PROVIDER_MAP (~24 entries) and TYPE_MAP
(~280 entries) from importers/pulumi/type-mapper.ts
(pre-extraction L94-413). The TYPE_MAP entries are the
SOURCE OF TRUTH for ICE iceType names — external consumers
depend on the exact dotted-form values; preserved verbatim.

Size exception: 376 LOC justified by data-only nature
(cf. /docs/refactoring-patterns.md 'Data-heavy shim split').

Tests: 18 cases pinning provider count, key existence, ICE
type format, dedup behaviour for collapsed mappings (e.g.
aws:s3/bucket:Bucket and aws:s3/bucketV2:BucketV2 both
mapping to aws.s3.bucket).

* rf-pmap-2: extract pulumi type-mapper URN/type parsers

Verbatim port of parse_urn and parse_type from
importers/pulumi/type-mapper.ts (pre-extraction L19-86).
Pure string parsers; no data-table dependency.

Tests: 19 cases covering URN parts validation, special
type handling (pulumi:pulumi:Stack, pulumi:providers:*),
standard format vs alternative format priority order,
malformed input fallback to empty object.

* rf-pmap-3: extract pulumi type-mapper mapping helpers

Verbatim port of get_ice_type, get_ice_provider,
get_provider_from_type, is_type_supported,
get_supported_types, get_supported_ice_types,
get_name_from_urn, is_provider_resource, is_stack_resource
from importers/pulumi/type-mapper.ts (pre-extraction
L422-527). Plus the private to_snake_case helper
(pre-extraction L500-505).

Pre-extraction quirks preserved:
 - get_ice_type three-stage fallback (TYPE_MAP -> synth -> lowercase dotted)
 - get_ice_provider three-stage fallback (URN -> type -> simple-name -> 'unknown')
 - is_type_supported strict TYPE_MAP membership (synthesised paths excluded)
 - get_supported_ice_types deduped via Set
 - to_snake_case strips leading underscore from ([A-Z]) capture

Tests: 21 cases covering all eight helpers + fallback paths.

* rf-pmap-4: slim pulumi type-mapper orchestrator (527 -> 42 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the (implicit) data-table re-exports keep
their pre-extraction shapes. External consumers
(state-importer.ts, parsing.ts, resource-conversion.ts,
index.ts) continue importing through this shim path
without changes.

* rf-galg-1: extract graph topological-sort + cycle detection

Verbatim port of topological_sort, reverse_topological_sort,
has_cycle, find_cycles, find_cycle_in_subgraph from
graph/algorithms.ts (pre-extraction L18-220). Grouped together
because topological_sort uses find_cycle_in_subgraph (private)
for error reporting; has_cycle is a one-line wrapper around
topological_sort.

Pre-extraction quirks documented:
 - topological_sort double-counts in-degree decrement (iterates
   outgoing edges + ALL edges with target === current)
 - find_cycles returns DUPLICATE cycles when nodes participate
   in multiple cycles (no dedup)
 - find_cycle_in_subgraph returns first cycle OR up to 5 nodes
   from input on failure (best-effort error fallback)

Tests: 16 cases covering linear chains, cycles, disconnected
nodes, non-depends-on edge filtering. Plus shared fixtures.ts
with make_graph helper for the rest of the rf-galg series.

* rf-galg-2: extract graph path-finding helpers

Verbatim port of find_all_paths and find_shortest_path from
graph/algorithms.ts (pre-extraction L229-297). Independent
DFS/BFS path finding; no dependency on topo/cycle/components.

Pre-extraction quirks documented:
 - find_all_paths uses recursive DFS with visited-set cycle
   avoidance + max_paths cap (default 100)
 - find_shortest_path uses Array.shift BFS (O(n) per shift,
   not O(1)); preserved verbatim
 - find_shortest_path returns null on no path, [start] when
   start === end

Tests: 12 cases covering direct edges, diamonds, cycle
avoidance, max_paths cap, null vs empty array.

* rf-galg-3: extract connected components helpers

Verbatim port of find_connected_components and
find_strongly_connected_components from graph/algorithms.ts
(pre-extraction L307-402). Two distinct algorithms:
 - find_connected_components: BFS, treats edges as UNDIRECTED
   (uses both incoming and outgoing edges)
 - find_strongly_connected_components: Tarjan's, treats edges
   as DIRECTED; filters single-node SCCs (length > 1 only)

Pre-extraction quirks documented:
 - find_connected_components tolerates duplicate enqueue (the
   visited.has check is at top of inner loop, not at enqueue)
 - find_strongly_connected_components excludes singleton SCCs
   (intentional — singleton means trivially-strongly-connected,
   so excluding gives meaningful SCCs only)
 - Recursive Tarjan can hit JS stack limits on deep graphs

Tests: 12 cases covering empty graphs, disconnected nodes,
diamonds, mutual cycles, multi-cycle graphs.

* rf-galg-4: extract dependency analysis + graph metrics

Verbatim port of get_execution_layers, get_critical_path,
calculate_metrics, GraphMetrics interface from
graph/algorithms.ts (pre-extraction L412-586). Depends on
helpers from topo-cycle and components modules.

Pre-extraction quirks documented:
 - get_execution_layers uses iterative layer-peel; on cycle
   produces empty layer and breaks (silent ceasing)
 - get_critical_path KNOWN BUG: distance update walks
   incoming-edge predecessors, but topo order processes leaves
   first for depends_on graphs, so the source-distance lookup
   reads -Infinity and the chain never propagates. Effectively
   returns just the start (no-deps) node. Preserved verbatim
   — fixing this changes public behaviour, out-of-scope for
   refactor.
 - calculate_metrics density formula: e/(n*(n-1)); guards
   max_edges > 0 to avoid div-by-zero on empty graphs

Tests: 15 cases covering empty graphs, parallelisable layers,
cycle detection, density edge cases, degree statistics. Two
critical-path tests assert length >= 1 (preserving the
pre-extraction quirk).

* rf-galg-5: slim graph algorithms orchestrator (586 -> 51 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the GraphMetrics type keep their pre-extraction
shapes. External consumers (graph/index.ts,
plan/plan-engine.ts, graph/validator/validators.ts) continue
importing through this shim path without changes.

* rf-pmap rf-galg: append learnings on critical-path quirk + data-table LOC exception

* rf-cmove: split use-container-move.ts (564 → 189 LOC orchestrator)

Decomposes the rf-canv-25b useContainerMove hook into pure runners
under hooks/container-move/. Behavior preserved verbatim — both
handleNodeMove and handleToggleFold delegate to the new sub-modules.

  - container-move/types.ts (25) — PositionUpdate / SizeUpdate
  - container-move/ancestor-expansion.ts (275) — walkAncestorsAndExpand,
    expandAncestorOnce (shared by both handlers, parameterized over
    sibling-bounds reading strategy)
  - container-move/clamp.ts (114) — clampDraggedNodeToParent +
    detectExitingGroupId (tri-state setter directive)
  - container-move/move-runner.ts (127) — runNodeMove pure runner
  - container-move/toggle-fold-runner.ts (187) — resolveToggleFoldDecision
    + runUnfoldExpansion pair

Tests: 27 baseline (use-container-move.test.tsx) + 68 new sub-module
tests = 95 total passing. Coverage on container-move/: 100% statements
/ 100% branches / 100% functions / 100% lines (types.ts excluded as
runtime-empty).

Per blueprint risk #2 — DO NOT consolidate the four ancestor-expansion
sites with rf-canv-4's expandToFitChildren; they have subtly different
rules. The split preserves the per-handler bbox-reading strategy via
the siblingPosLookup + siblingBoundsOverride params on
walkAncestorsAndExpand.

* rf-vval: split validators.ts (524 → 68 LOC orchestrator)

Decomposes the built-in validators into domain-grouped modules under
graph/validator/validators/. Public API and behavior preserved verbatim
— the orchestrator file becomes a thin re-export shim plus the two
factory functions.

  - validators/structure.ts (163) — Cycle, Reference, Naming,
    Connectivity (graph topology + names, no schema dep)
  - validators/schema.ts (195) — Type, Property (schema-provider deps)
  - validators/security.ts (138) — SensitiveData, BestPractices
  - validators.ts (68) — re-exports + create_builtin_validators +
    create_configured_validator factories

Tests: 3 baseline (core.test.ts Graph Validator suite) + 70 new
sub-module tests = 73 total passing. Coverage on
validators/{structure,schema,security}.ts + validators.ts: 100%
statements / 100% branches / 100% functions / 100% lines.

The public exports from packages/core/src/index.ts (CycleValidator,
ReferenceValidator, ..., create_builtin_validators,
create_configured_validator) remain wired through the orchestrator
shim, so external consumers and the dist barrel don't change.

* rf-ierr: split import-errors.ts (507 → 30 LOC orchestrator)

Decomposes the import error classification system into per-cloud
modules under errors/import-errors/. Behavior + every error message
string preserved verbatim (user-facing strings are stable).

  - import-errors/types.ts (117) — ImportErrorCode enum,
    ImportErrorAction, ImportError, ImportWarning, ImportErrorActionType
  - import-errors/gcp.ts (145) — classifyGCPError
  - import-errors/aws.ts (144) — classifyAWSError
  - import-errors/azure.ts (119) — classifyAzureError
  - import-errors.ts (30) — re-export shim, public API unchanged

Tests: 0 baseline + 87 new sub-module + shim tests = 87 total. Coverage
on import-errors/{types,gcp,aws,azure}.ts: 100% statements / 100%
branches / 100% functions / 100% lines. The shim file shows 0% in v8
coverage (re-only-export quirk; the named imports through it are
exercised by the dedicated shim test file).

External consumers (aws-importer.ts, azure-importer.ts,
gcp/services/asset-inventory.ts, errors/index.ts barrel) keep their
original `from '../../errors/import-errors.js'` import paths.

Full core suite: 2472 tests still pass.

* rf-cmove: append learning on tri-state setter directive pattern

* Phase 3 complete: all 18 files in 500-600 LOC band refactored

64 commits across 6 cohorts. 9756 → 3441 LOC orchestrators (-65%).
+1356 tests added. 3 pre-existing bugs surfaced and documented
(preserved verbatim per refactor discipline).

Remaining files over 500 LOC are documented exceptions only:
- Generated (resource-types.ts)
- Data-heavy (high-level-resources, scale-presets-data, cloud-blocks-data,
  themes, ast/types)
- Refactored orchestrators with residual cohesion (deploy.service,
  svg-canvas, system-prompt)

The actionable refactor queue is empty.

* rf-deploy2-1: extract planDeployment + fallbackPlan to plan-deployment.ts

Pulls planDeployment (~85 LOC) and its module-private fallbackPlan
(~44 LOC) out of deploy.service.ts into a dedicated module. The
orchestrator re-exports planDeployment so the namespace import in
routes/canvas-deploy.ts and the export * in services/deploy/src/index.ts
keep resolving unchanged.

deploy.service.ts: 1572 → 1449 LOC.
Adds plan-deployment.test.ts covering happy path + translator-throw fallback.

* rf-deploy2-2: extract applyDeployment to apply-deployment.ts

Pulls applyDeployment (~573 LOC) — the 5-phase translate/auth/deploy
pipeline — out of deploy.service.ts into a dedicated module. The
orchestrator re-exports applyDeployment so the namespace import in
routes/canvas-deploy.ts and the named import in queue.service.ts
continue to resolve unchanged.

deploy.service.ts: 1449 → 894 LOC.
apply-deployment.ts: 600 LOC (slightly over 500-line ceiling; flagged
for follow-up internal split per brief).

* rf-deploy2-3: extract destroyAllForCard to destroy-all-for-card.ts

Pulls destroyAllForCard (~242 LOC) — the 'nuke' path that destroys every
ICE-managed resource for a card across all historical deployments — out
of deploy.service.ts into a dedicated module. The orchestrator re-exports
destroyAllForCard so the namespace import in routes/canvas-deploy.ts
keeps resolving unchanged.

deploy.service.ts: 894 → 644 LOC.
destroy-all-for-card.ts: 281 LOC.

* rf-deploy2-4: extract destroyDeployment to destroy-deployment.ts

Pulls destroyDeployment (~338 LOC) — the latest-apply-baseline destroy
path with reverse-order delete loop and pdl-10 per-resource node_status
emit — out of deploy.service.ts into a dedicated module. The orchestrator
re-exports destroyDeployment so the namespace import in
routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 644 → 311 LOC.
destroy-deployment.ts: 364 LOC.

* rf-deploy2-5: extract rollbackDeployment to rollback-deployment.ts

Pulls rollbackDeployment (~187 LOC) — the rebuild-from-target-deployment
diff/deploy path — out of deploy.service.ts into a dedicated module.
The orchestrator re-exports rollbackDeployment so the namespace import
in routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 311 → 129 LOC.
rollback-deployment.ts: 210 LOC.

* rf-deploy2-6: orchestrator slim-down — drop unused imports + docstring

Trims now-unused imports from deploy.service.ts (acquireDeployLock,
DeployLockError, providerService, getResourceMap, etc.) and updates the
top-of-file docstring to describe the file's new role: a thin re-export
shim plus 5 small DB-only helpers and the snapshot-persister side-effect
init. Body is unchanged behaviorally.

deploy.service.ts: 129 → 107 LOC (1572 → 107 across the rf-deploy2 series,
93% reduction). Public API surface preserved: every symbol exported
pre-refactor still resolves through the orchestrator (planDeployment,
applyDeployment, destroyAllForCard, destroyDeployment, rollbackDeployment,
getDeploymentStatus, getDeployedResources, getDeploymentHistory,
requestDeployCancel, getCurrentDeploySnapshot, DeployProgressSnapshot,
getNodeDeploymentOverlay, checkDrift).

* rf-deploy2-2 housekeeping: split apply-deployment.ts under 500 LOC

apply-deployment.ts landed at 600 LOC after rf-deploy2-2 (just over the
project's 500 LOC ceiling). This follow-up extracts five mechanically-
separable helpers into apply-pipeline-helpers.ts, bringing the orchestrator
under the ceiling without changing the apply pipeline's behavior:

- logSourceRepoDiagnostics: pre-deploy canvas-shape diagnostic log lines
- ensureAutoDeployRules: best-effort Source.Repository → Compute auto-rule writer
- logDiffForDebugging: console-only desired-vs-current node dump
- normalizeIdempotentResultErrors: NOT_FOUND/ALREADY_EXISTS rewrites
- persistResourceMappings: post-deploy mapping-table writes (in-place mutation)

apply-deployment.ts: 600 → 493 LOC.
apply-pipeline-helpers.ts: 220 LOC.
Adds 11 tests covering the three helper shapes that benefit most from
isolated test coverage.

* rf-deploy2: append awk-line-range learning from rf-deploy2-2

* rf-hlres-1: extract HighLevel* type interfaces to high-level-resources/types.ts

Pulls the 5 type interfaces (ProviderImplementation, HighLevelResource,
OptionDetail, HighLevelProperty, HighLevelCategory) plus the NodeBehavior
re-export out of the orchestrator. The shim now re-exports each name
verbatim, preserving the public API for both packages/core/src/resources/index.ts
and packages/core/src/index.ts.

Smoke test pins:
- the types module is loadable as a namespace
- structurally-typed values for each of the 5 interfaces compile
- the shim re-exports the 5 type names + NodeBehavior
- HIGH_LEVEL_CATEGORIES still has the 7 canonical category ids in order

Baseline typecheck preserved at 29 TS2834 errors (all in unrelated barrel
files); no new errors in touched paths.

* rf-hlres-2: extract compute category to high-level-resources/categories/compute.ts

Cuts the 'compute' object literal (~1779 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES and re-imports it as a const. The orchestrator
shrinks from 6354 to 4577 LOC.

Size exception documented in the file header — the file is dominated by
data (per-resource property catalogues, instance-size pickers, provider
implementations) and would only fragment further without improving
readability.

Smoke test pins the 13 canonical resource ids in order plus the shape
of frontend-app and backend-api. Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-3: extract database category to high-level-resources/categories/database.ts

Cuts the 'database' object literal (~2167 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 4577 to 2411 LOC.

Size exception documented in the file header — same shape as compute.

Smoke test pins the 13 canonical database resource ids in order
(postgres-db through search-engine) plus the shape of postgres-db
and redis-cache. Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-4: extract storage category to high-level-resources/categories/storage.ts

Cuts the 'storage' object literal (~449 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 2411 to 1964 LOC.

Smoke test pins the 5 canonical storage resource ids in order
(object-storage, oss, oci-object-storage, do-spaces, file-storage).
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-5: extract networking category to high-level-resources/categories/networking.ts

Cuts the 'networking' object literal (~559 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1964 to 1407 LOC.

Smoke test pins the 7 canonical networking resource ids in order
(public-endpoint, vpc-network, subnet, load-balancer, cdn, api-gateway,
dns-zone). Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-6: extract messaging category to high-level-resources/categories/messaging.ts

Cuts the 'messaging' object literal (~842 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1407 to 567 LOC.

Smoke test pins the 7 canonical messaging resource ids in order
(message-queue, event-bus, rabbitmq, cloud-pubsub, service-bus,
email-service, event-stream) and that message-queue carries the deep
optionDetails arrays used by the AWS / GCP / Azure queue-type picker.
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-7: extract security + monitoring categories (combined unit)

Cuts the last two object literals (security ~185 LOC, monitoring ~185 LOC)
verbatim out of HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 567
to 200 LOC — 7 categories now imported, no inline literals remain in
HIGH_LEVEL_CATEGORIES.

Both files are well under the 500 LOC ceiling; split out for symmetry
with the rest of the categories sub-tree.

Smoke tests pin the canonical resource ids in order: security
(secret-store, ssl-certificate, service-account) and monitoring
(log-group, alert, dashboard). Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-8: extract helpers + HIGH_LEVEL_CATEGORIES to high-level-resources/helpers.ts

Moves the 7 helper functions (getAllHighLevelResources, palette projection,
filterResourcesByProvider, getBehaviorLabel/Color, getGCPCloudAssetTypes,
cloudAssetToHighLevelType) plus the PULUMI_TO_CLOUD_ASSET map and the
HIGH_LEVEL_CATEGORIES assembly into a new helpers module.

helpers.ts owns the runtime origin of HIGH_LEVEL_CATEGORIES because the
cloud-asset helpers iterate over it — making the helpers the runtime owner
avoids a 'helpers → orchestrator → helpers' cycle. The orchestrator now
re-exports each runtime symbol verbatim so external consumers
(packages/core/src/index.ts, packages/core/src/resources/index.ts,
schema-bridge, property-rules, gcp asset-inventory, type-mapper) keep
the same import surface.

Exhaustive tests pin: HIGH_LEVEL_CATEGORIES order, getAll flatMap +
ordering, palette projection (ice_type / display_name / category /
behavior / providers / implementations / properties), provider filter
+ 'all' bypass + unknown provider returns empty, behavior label/color
delegation, GCP asset-types deduplication, and cloudAsset→HL reverse
lookup including null on unknown.

Orchestrator file: 202 → 37 LOC. Helpers: 195 LOC. Baseline typecheck
preserved at 29 TS2834 errors. Full @ice/core suite (2515 tests) passes.

* rf-hlres-9: orchestrator slim-down — finalize the high-level-resources shim

Polishes the orchestrator after rf-hlres-8 left it as a 37-LOC re-export
shim. Drops the in-progress wording from the docstring, merges the two
`export type {}` blocks into one, and adds section dividers so future
readers can spot the type re-exports vs. the runtime re-exports at a
glance.

Final file size: 47 LOC. Net delta on the rf-hlres series:
  - high-level-resources.ts: 6434 → 47 LOC (a ~99.3% shrink)
  - new files: types.ts, helpers.ts, 7 categories/<name>.ts
  - public API surface unchanged (every name still resolves on the shim)

Repo-wide test run after the slim-down: 8144 tests pass. Baseline
@ice/core typecheck preserved at 29 TS2834 errors (all unrelated).

* rf-hlres: append learning on helpers-owns-assembled-array pattern + awk per-splice tactics

* rf-svgcv2-1: extract pan/zoom transform group as CanvasContent

The inner <g transform=...> body — grid, selection frame, both connection
layers, clipPaths, nodes layer, connection-drawing preview, user-traffic
overlay, and ghost overlay — moves into canvas-renderer/canvas-content.tsx.
Visual draw order, prop flow, and gating logic preserved verbatim.

svg-canvas: 570 → 490 LOC.
canvas-content: 236 LOC (new).

Tests: 10 passing — direct-FC tree-walker matching mocks by reference.
Typecheck clean.

* rf-svgcv2-2: extract SVG mouse-event routing as useCanvasMouseRouting

The four inline arrow handlers on the <svg> element — onMouseDown
(connection-port classList sniff + bindCanvas fall-through), onMouseMove
(drawingConnection branch + bindCanvas fall-through), onMouseUp (same
shape), and onMouseLeave (tooltip dismiss + bindCanvas) — collapse into
a hook that returns the spread-able handler bundle.

Behavior preserved verbatim: classList sniff stays orchestrator-side (per
RISK #5), tooltip dismissal still fires on every onMouseDown/onMouseLeave,
onAuxClick / onContextMenu pass through bindCanvas references unchanged.

svg-canvas: 490 → 473 LOC.
use-canvas-mouse-routing: 114 LOC (new).

Tests: 12 passing — covers all four routing seams + identity-pass
through for AuxClick/ContextMenu. Typecheck clean.

* rf-svgcv2-3: extract selection-dispatch + snapToGrid wiring as useCanvasInteractionsBindings

The orchestrator's useCanvasInteractions call carried three inline
arrow-function callbacks (onSelect / onToggleSelect / onBoxSelect)
plus the snapToGrid → gridSize ternary. Both groups are now bundled in
a thin wrapper hook that calls useCanvasInteractions internally.

Behavior preserved verbatim — the same dispatch sequences fire for each
selection-change branch, and snapToGrid still toggles between GRID_SIZE
and 0. The wrapper drops the snapToGrid prop from the inner args object
so useCanvasInteractions sees only its own keys.

svg-canvas: 473 → 457 LOC.
use-canvas-interactions-bindings: 69 LOC (new).

Tests: 8 passing — covers each dispatch callback, the gridSize ternary,
and the snapToGrid prop-drop. Typecheck clean. Full canvas-tests suite:
207/207 passing.

* rf-svgcv2-4: extract RenderCtx assembly as useRenderCtx hook

The eighteen-field renderCtx object the per-node renderer dispatch
consumes — sortedNodes, lod, pipelineNodeStatus, drag/exit highlight
ids, the seven event-handler callbacks, etc. — moves into a
useRenderCtx hook. The hook also binds getConnectedPipelineStatuses
to (card, pipelineNodeStatus) so renderers call it as a single-arg
function (preserving rf-canv2-5's surface).

Behavior preserved verbatim: the returned ctx contains every field
the original literal had, with the same refs. card and zoom move
into the hook's args bundle; svg-canvas no longer imports
getConnectedPipelineStatuses or the RenderCtx type.

svg-canvas: 457 → 453 LOC.
use-render-ctx: 105 LOC (new).

Tests: 5 passing. Bonus: rf-svgcv2-3 test JSX-form fix (Provider needs
explicit children prop in TS strict — convert createElement(Provider,
{store}, child) to JSX form). Typecheck clean.

* rf-spr2-1: split system prompt into composable section builders

The 350-line static prose body of buildSystemPrompt's template literal
moves into seven section-builder functions in system-prompt-sections.ts:
  - buildHeaderPrompt(dominantProvider)
  - buildIntentRoutingPrompt() — WHEN TO ACT vs WHEN TO ASK
  - buildOperationsPrompt(availableBlockTypes) — STRICT BLOCK REGISTRY
  - buildPropertyPrefillPrompt() — PROPERTY PRE-FILL RULES
  - buildOptimizationGuidelinesPrompt() — improve security/cost/HA/...
  - buildCanvasContextPrompt(nodes/edges/selected/schema) — Current Canvas
  - buildContainerNetworkingPrompt(connectionPrompt) — VPC + WIRING

The orchestrator concatenates them via the + operator. Output is
byte-identical to the pre-rf-spr2 prompt — verified by a new snapshot
test (3 fixtures: bare canvas, cloud-architect skill, question intent
with deployment context). Pure helpers (formatNodesSummary,
detectDominantProvider, buildCloudArchitectPrompt) stay in
system-prompt.ts.

system-prompt.ts: 516 → 203 LOC.
system-prompt-sections.ts: 420 LOC (new, prose-heavy — header documents
the data-table exception).

Tests: 33 passing (30 existing + 3 new snapshot fixtures).
Typecheck clean.

* rf-asttyp-1: split AST types by category, keep ast/types.ts as re-export shim

The 581-LOC types.ts split into four sub-files by AST node category:
  - types/base.ts        — AstNode + AstNodeKind union (65 LOC)
  - types/expressions.ts — Expression + literals + access + splat +
                            for-comprehension types (253 LOC)
  - types/blocks.ts      — Block + Attribute + NestedBlock (44 LOC)
  - types/statements.ts  — Program + Statement union + every top-level
                            block + LifecycleConfig + ValidationRule +
                            TypeExpression (268 LOC)

ast/types.ts becomes a 70-LOC type-only re-export shim. Every existing
consumer (the parent ast.ts shim, ast-helpers.test.ts, plus the broader
parser/visit_ast machinery) keeps importing from '../ast/types.js'
unchanged.

Tests: 15 type-shape tests added in __tests__/types-shim.test.ts —
covers every kind discriminator + the optional-field shapes for
ResourceBlock/LifecycleConfig/TypeExpression. Full parser test suite:
297/297 passing. Typecheck: no new errors (existing 29 TS2834 baseline
errors unaffected per the ts2834-baseline learning).

* rf-svgcv2-4 followup: fix RenderCtx → Record cast in test (TS2352)

Two-step cast (as unknown as Record) to satisfy strict overlap check —
RenderCtx has no index signature so the direct cast fails.

* rf-svgcv2 + rf-spr2-1 + rf-asttyp-1: capture three new learnings

- byte-identity-snapshot-must-be-captured-pre-refactor-not-post (rf-spr2-1)
- svg-canvas-orchestrator-loc-budget-flexible-with-renderctx-bundling (rf-svgcv2-4)
- render-ctx-record-cast-needs-double-as-unknown-as-record (rf-svgcv2-4)

* rf-spdat: split scale-presets-data 1482 LOC into 7 category files + 46-LOC orchestrator

Extract SCALE_PRESETS dict by domain category (compute, database, storage,
networking, messaging, security, monitoring) under scale-presets-data/.
Orchestrator becomes a 46-LOC re-export shim that spreads each per-category
record into the SCALE_PRESETS dict — order preserved.

Per-category sizes (LOC): compute 387, database 500, messaging 265,
networking 166, storage 142, security 30, monitoring 30. All categories
data-only (size-exception applies).

Smoke test (scale-presets-data.test.ts): 14 tests pinning category counts,
no cross-category collisions, assembled-dict equals union, byte-identical
sample lookups for compute/database/monitoring tiers.

* rf-cbdat: split cloud-blocks-data 1009 LOC into 9 category files + 126-LOC orchestrator

Extract the 16 BlockTemplate entries by their declared `category:` field
under cloud-blocks-data/ (frontend, backend, compute, data, storage,
networking, messaging, observability, security). Orchestrator assembles
BLOCK_TEMPLATES in the documented original order and derives BLOCK_CATEGORIES
(palette grouping) by category-field filter.

Per-category sizes (LOC): backend 210, data 192, networking 128, messaging
106, security 106, frontend 79, storage 72, compute 70, observability 65.

Smoke test (cloud-blocks-data.test.ts): 17 tests pinning per-category
counts, no cross-category template-name collisions, byte-stable assembled
ordering vs. the original file, BLOCK_CATEGORIES references the assembled
list (filter, not clone).

* rf-thmdat: split themes 590 LOC into 3 4-theme groups + 30-LOC orchestrator

Extract the 12 ColorTheme entries into 3 group files (4 themes each) by
the original ordering: group-1 default/retro/cupcake/valentine (warm),
group-2 synthwave/coffee/luxury/aqua (bold), group-3 forest/sage/dracula/
night (nature/dark). Orchestrator becomes a 30-LOC re-export shim that
spreads the three groups into the public T array.

Per-group sizes: 200/201/201 LOC — each well within the 200-500 ceiling.

Smoke test (themes-data.test.ts): 12 tests pinning per-group counts and
ordering, no cross-group id collisions, byte-stable assembled T array,
reference-equality preserved through the spread (orchestrator FC depends
on T[i] identity for the shallow click-handler closures).

* Final round complete: all exceptions decomposed (excluding generated files)

8 exception files split: deploy.service, high-level-resources, svg-canvas,
system-prompt, ast/types, scale-presets-data, cloud-blocks-data, themes.

12,754 → 1,082 LOC across orchestrators (-91%). 28 commits, +180 tests.

Files over 500 LOC remaining: only the generated resource-types.ts
(excluded per user) and 4 high-level-resources category sub-modules
(pure resource-definition data with SIZE EXCEPTION headers; further
fragmentation would split related resources without maintainability
gain).

* bugfix-2: lazy require.resolve in get_base_db_path

The pre-fix code eagerly evaluated
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` while
constructing the candidate array, so the function threw before
reaching the existsSync loop in any environment where that package
isn't installed (test envs, fresh checkouts, this monorepo's dev
setup). Wrap each candidate in a thunk and the require.resolve
fallback in its own try/catch so a missing package degrades to
"skip this candidate" instead of crashing the whole call.

Tests:
- Confirm get_base_db_path() does not throw without the package.
- Confirm it returns a string ending in ice-schemas.db.
- Confirm it returns the dev path when staged at the resolved location
  (priority over the missing-package fallback).

Discovered during rf-cload-3 (extraction of `get_base_db_path` from
`customization-loader.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* bugfix-3: get_critical_path now propagates distance through chain

Pre-fix the distance update walked `get_incoming_edges` and read
the *source's* distance, but `topological_sort` emits leaves first
for `depends_on` graphs — so source nodes are processed AFTER the
current node and their distance is always -Infinity at lookup
time. The chain never propagated past the start node and the
function returned `[start]` for any DAG.

Fix: walk `get_outgoing_edges` (the current node's dependencies)
and read the *target's* distance. Targets are processed earlier
in topo order (leaves first), so the lookup is always populated.

Tests:
- 3-node chain a→b→c → returns [c, b, a] (was [c]).
- 4-node chain a→b→c→d → returns [d, c, b, a].
- Diamond DAG (a→b, a→c, b→d, c→d) → 3-node longest path.
- Isolated node → returns [solo].
- Empty graph → [].
- Cyclic graph → [].
- Disconnected components → returns longest among them.
- Two pre-existing softened tests (`expect(path.length)
  .toBeGreaterThanOrEqual(1)`) removed; replaced by strict
  assertions that pin the corrected length and node order.

Path orientation: leaf-first → root-last, matching the natural
"start → end" reading of a critical path. For chain a→b→c the
"start" is c (no deps) and the "end" is a (most-dependent).

Discovered during rf-galg-4 (extraction from `graph/algorithms.ts`);
preserved verbatim then per the verbatim-preservation rule for
refactors. Now fixed as a behaviour-change PR per the rule that
pre-existing bugs documented in learnings get separate fix
tickets.

* bugfix-4: detectJsFramework now reads pnpm/yarn lockfile signals

Pre-fix `filesToCheck` had only 6 entries — `package.json`,
`Dockerfile`, `requirements.txt`, `go.mod`, `pom.xml`, `Cargo.toml`.
The detector iterated these and pushed found names into
`detectedFiles`, but the JS-ecosystem branch in `detectJsFramework`
checked `detectedFiles.includes('pnpm-lock.yaml')` /
`yarn.lock` — neither of which was in `filesToCheck`. So the
package-manager guess always fell through to npm regardless of
which lockfile the repo actually had.

Fix: add the three JS lockfiles to `filesToCheck` so they show up
in `detectedFiles` when present. Three extra Contents-API GETs per
detection call; cheap given GitHub's rate limits and one-shot
nature of this path.

Tests:
- pnpm-lock.yaml present → packageManager='pnpm',
  installCommand='pnpm install --frozen-lockfile'.
- yarn.lock present → packageManager='yarn',
  installCommand='yarn install --frozen-lockfile'.
- package-lock.json present → packageManager='npm' (default fall-
  through preserved; ladder has no explicit branch for it).
- No lockfile → packageManager='npm' (still works).
- pnpm + yarn both present → pnpm wins (ladder priority pinned).
- Removed the test that pinned the broken behavior ("package.json
  defaults to npm package manager (lock files are not in
  detectedFiles list)") — replaced by the corrected
  pnpm/yarn/no-lockfile triplet.

Bundles the SHA fixup for the bugfix-3 `_Fixed:_` line in
state/learnings.md.

Discovered during rf-pipe-6 (extraction of `framework-detection.ts`
from `pipeline.service.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* learnings: append bugfix-2/3/4 sweep gotcha (Fixed-line audit trail)

Capture the rule of thumb that emerged from the bugfix sweep: only
the rf-galg-4 quirk had a dedicated learning anchor; the other two
bugs were documented inline in their SUT header comments. The
brief assumed every bug had its own anchor for the `_Fixed:_`
audit trail; reality is sparser and the implementer should pick
between inline-doc and learning-anchor based on whether the
deferred fix is a real future ticket.

* progress: archive entry for completed LOC discipline initiative

Multi-day refactor work (2026-04-29 → 2026-05-02) marked complete in
the Archive section of progress.md. Cross-references to phase totals,
bug fixes, and the new /docs/refactoring-patterns.md doc.

Cumulative: ~470 commits, ~7500 new tests, 73 files refactored,
4 latent bugs fixed, 1 generated file excluded, 4 data-leaf
exceptions remaining (documented).

Codebase decomposition is complete. Pausing here.

* progress: housekeep In flight, move pdl + rf-* detail trail to Archive

Reduces progress.md from 322 → 216 lines. In flight section now contains
just the 6 deferred follow-ups (pdl-11, rollupPercentage, nodesById warm-
seed, dead snapshot fields, data.status fallback, 3 rf-0c dedups). All
pdl-1..10 and rf-deploy/rf-props/rf-canv/rf-pdpl/rf-ctrans/rf-cards/
rf-fbh/rf-parse subsections moved verbatim under Archive so anchor/commit
references remain searchable.

* decisions: 2026-05-02 merge-story entry for refactoring branch

Document the choice to merge the 509-commit refactoring branch as a
single PR rather than splitting per phase. Rationale and alternatives
captured in the decision entry; consequences note that deferred follow-
ups (pdl-11 etc.) ship as separate PRs after the merge.

* fix(test): cast source_node_id read in apply-pipeline-helpers test

The helper mutates result.resources[i].source_node_id at runtime via
res: any. The test fixture's inline literal infers a strict type
without source_node_id, so the post-call assertion fails TS2834.
Add { source_node_id?: string } cast at the two assertion sites to
match the runtime mutation contract.

* state: archive pre-compaction snapshot of learnings.md (2026-Q2)

Verbatim copy of learnings.md before the Q2-2026 compaction pass.
Per CLAUDE.md schedule: cluster duplicates once per quarter, archive
the pre-compaction file as state/archive/learnings-YYYY-Qn.md, then
write the compacted version back. The compacted version lands in the
next commit.

204 anchors / 1676 lines preserved here for reference.

* state: compact learnings.md (Q2-2026 pass)

Cluster duplicates and trim redundancy after the parallel-deploy +
LOC-discipline initiatives. 204 → 113 anchors (-44%), 1676 → 780
lines (-53%). All 25 must-preserve anchors retained verbatim (the 24
referenced from /docs/refactoring-patterns.md plus the read-state-first
anchor cited in decisions.md and CLAUDE.md). Both _Promoted to:_ and
_Fixed:_ trailers preserved verbatim.

Representative cluster merges (canonical ← folded):
- ux-log-terminal-pitfalls ← 5 ux-log-* siblings
- ux-deploy-real-cloud-pitfalls ← 3 ux-deploy-* siblings
- pdl-7-wire-contract-trims-downstream-ui ← 3 pdl-7 follow-ups
- pdl-10-destroy-snapshot-and-dedup-traps ← 3 pdl-10 follow-ups
- ux-pdl-smoke-test-pitfalls ← 3 ux-pdl-* siblings
- inline-classification-duplications-are-not-actually-duplicates
  ← 4 sibling rf-canv anchors about inverted tie-breaks
- test-helper-defaults-traps-coalesce-and-spread ← 3 helper-default traps
- brief-numerics-are-approximate-source-is-canonical ← absorbed 3 brief-vs-source variants

Pre-compaction snapshot archived in the prior commit at
state/archive/learnings-2026-Q2.md.

* pdl-11: default node.data.provider to active deploy provider on drop

Closes the deferred follow-up from the parallel-deploy initiative
recorded in state/progress.md In flight. Palette drops without an
explicit `application/ice-block-provider` key (or with the 'all'
sentinel) now fall back to `state.deploy.provider`, so the deploy
panel doesn't filter newly-dropped blocks as "skipped — non-<provider>"
when the toolbar provider is set.

Three drop branches:
- Group drop: unchanged (groups have no provider).
- Block drop: paletteProvider 'all' or unset → effectiveProvider
  becomes deployProvider; threaded into both getBlueprint and
  expandBlueprint. Explicit palette providers (aws/azure) still win.
- Resource drop: newNodeData.provider is set to deployProvider.

logBlueprint still records the *palette* provider (analytics tracks
user intent, not the post-fallback value).

Tests: 21 → 23 in use-canvas-drop.test.tsx (+ 2 pdl-11 cases:
fallback-when-palette-omits, palette-wins-over-deploy-provider). One
existing test assertion updated from `provider: 'all'` to `provider:
'gcp'` to match the new behavior. Resource-drop case asserts
`data.provider === 'gcp'` (default deploy provider in the test store).

* deploy: extract deriveRollupPercentage helper from 3 inline copies

Closes pdl-5 critic findings #2 and #4 (deferred to follow-up). The
cap-at-99 progress formula was inlined identically in three places:
  - features/deploy/components/deploy-in-flight-panel.tsx:50
  - features/canvas/components/deploy-banner.tsx:94
  - shared/components/status-bar.tsx:216

Extracted to deploy/derive.ts next to deriveRollup as a pure projection
from DeployRollup → 0..100 percentage. Re-exported from deploy-slice
alongside deriveRollup / orderNodesForPanel so existing import paths
keep resolving.

Tests: 5 new dedicated cases for deriveRollupPercentage (empty / full /
cap-at-99 boundary / rounding / defensive zero-total). The
deploy-in-flight-panel.test mock switched to importOriginal +
selective override so deriveRollupPercentage runs real (it's a pure
projection from the rollup the test already controls via
mocks.deriveRollup) — saves maintaining a parallel mock that has to
mirror the cap formula.

19 → 19 derive tests (+5), 53 → 53 deploy-in-flight tests (refactor
preserves behavior), 0 typecheck regressions.

* deploy: warm-seed nodesById from snapshot in Phase 2 (pdl-5 #7)

Closes pdl-5 critic finding #7 (deferred to follow-up). When a tab
joins mid-deploy, Phase 2 of useDeploySubscription pulls the persisted
snapshot from the gateway and was previously only mirroring the
overlay onto the canvas blocks. The deploy panel's per-row list,
which derives from `nodesById`, stayed empty until Phase 2.5's replay
loop completed — leaving a brief "Preparing…" sentinel window even
though the per-block overlays already showed the right colors.

The warm-seed dispatches synthetic `node_status` events (and
`node_progress` when the snapshot carries a step descriptor) for each
node in `snapshot.nodeStatuses`, so the panel's per-row list renders
immediately.

Synthetic events use seq=0 so any live event (or replayed event from
Phase 2.5) with seq>0 dedup-wins on the same node, overwriting the
warm-seed entry's empty resource_name / resource_type / action with
the authoritative wire values.

New helper: overlayToWireStatus inverts mapWireStatusToOverlay
(snapshot stores the post-mapping overlay string; warm-seed walks it
back to a wire DeployNodeStatus). Returns null for overlay strings
that don't round-trip — e.g. 'destroying' / 'gone' from pre-pdl-10
destroy paths — so warm-seed skips them and Phase 2.5 fills them in
from the event tape.

Tests: 14 → 17 in use-deploy-subscription.test.ts (+3 for
overlayToWireStatus: full inverse map, null fallback for unknown
strings, round-trip through mapWireStatusToOverlay).

* deploy: drop DeployProgressSnapshot dead fields

The frontend stopped reading snapshot.progress, snapshot.currentResource,
and snapshot.currentStep when pdl-5 rewired the deploy panel + canvas
banner to derive every in-flight signal from `nodesById` (populated by
the typed node_status / node_progress wire). The server-side writers
have been writing dead state ever since.

Removed:
- progress: number, currentResource?, currentStep? from
  DeployProgressSnapshot interface (deploy-locks.ts).
- progress: 0 seed in startDeploySnapshot.
- progress: 100 stamp in finishDeploySnapshot.
- updateDeploySnapshot helper (no remaining callers after this drop).
- progress / currentResource writes in scheduler-callbacks (the totals
  count bump is preserved — callers still read it after the deploy
  returns).
- progress: 0 in canvas-deploy.ts /current/:cardId fallback.

The Json column on the Prisma `canvas_deployment.snapshot` field is
schemaless, so removing keys from the TS type doesn't migrate. Old DB
rows still carry these fields; new rows won't. The shared-modules
registry entry for the deploy-locks export set was trimmed to match.

Tests: 32 → 30 in scheduler-callbacks (caps-at-99 + write-only-
currentResource cases were specifically about the dead-field semantics
and dropped; the count-bump-on-terminal and per-node-mirror cases
preserved).

* ui: drop data.status legacy fallback per one-status-source-deploy-status

The compact-node deploy-pill render path read
`(data.deploy_status as string) || (data.status as string) || ''`,
making `data.status` a de-facto status source no matter how careful
the writer side stayed. Per the `one-status-source-deploy-status`
learning, removing the OR makes `deploy_status` the single source of
truth.

Coordinated with the writer-side sweep so no node-creation site seeds
`status: 'active'` anymore — the field stays empty until the deploy
pipeline writes `deploy_status`.

Sites cleaned (writer side):
- packages/blocks/src/{aws,azure,gcp}/security/waf.ts (3 WAF blueprints)
- packages/blocks/src/expand-blueprint.ts (mergedData seed)
- packages/templates/src/expand-template.ts (group + fallback resource)
- packages/ui/src/features/canvas/hooks/use-canvas-drop.ts (group + resource branches)
- packages/ui/src/store/slices/cards/reducers/undo-redo-group.ts
  (groupSelectedNodes new container)

Drift-checker writes (use-drift-check.ts) re-routed from `status:
'drifted' | 'active'` to `deploy_status:`-prefixed equivalents — drift
state IS a deploy outcome, not a separate field.

Reader side: compact-node/index.tsx:81 fallback dropped + comment
rewritten to point at the learning anchor for context.

Tests updated: use-canvas-drop (group + resource asserts), use-drift-
check (3 dispatched-payload asserts, both 'drifted' and 'active'),
undo-redo-group (drop the bounding-box test's status assertion).
8261 unit tests passing across the workspace; 0 typecheck regressions.

* rf-0c: hoist mapStatusToOverlay + overlayToWireStatus into @ice/types

The wire-status → canvas-overlay mapping had three redundant homes:
  - services/deploy/src/utils/deploy-event-formatter.ts:mapStatusToOverlay
  - packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts
    :mapWireStatusToOverlay (+ overlayToWireStatus from 4c)

Both were kept in sync by hand and a long docstring warning
"Both sides must pick the same overlay string for the same wire status."
Drift between the two would mean a tab opened mid-deploy hydrates a
node with one color via the snapshot path and gets the same node
overwritten to a different color by the live event microseconds later.

Canonical home is now packages/types/src/deploy-events.ts next to
DeployNodeStatus. Service-side and UI-side modules re-export to
preserve consumer imports:
  - deploy-event-formatter.ts re-exports mapStatusToOverlay
  - use-deploy-subscription.ts re-exports
    `mapStatusToOverlay as mapWireStatusToOverlay` and overlayToWireStatus
    so existing UI callers (deploy-node-row, etc.) don't need a sweep

Also added DeployOverlayStatus type for callers that want the narrowed
return type of mapStatusToOverlay.

194 tests passing across @ice/types + @ice/ui + service-deploy; 0
typecheck regressions.

* rf-0c: unify network-container set via @ice/constants

Both @ice/types/connection-rules/predicates.ts:isContainer and
@ice/core/validation/classifiers.ts:isContainer hardcoded the same
list of network container iceTypes:
  - 'Network.VPC'
  - 'Network.Subnet'
  - 'Network.PrivateNetwork'

@ice/core/graph/classifier/category-classifier.ts:is_container_type
already read the canonical list from
@ice/constants:NETWORK_CONTAINER_TYPES — flipping the two `isContainer`
predicates to use the same constant means a new container type
(e.g. a future Network.PrivateLink) shows up everywhere in lockstep
without three coordinated edits.

The Group.* and nodeType-=='container'/'group' branches stay specific
to the connection-rules / validation `isContainer` predicates — they
serve a different purpose than the type-only `is_container_type`
function (which answers "is this a network container resource?").

The wider classifier dedup (the predicate set duplicated between
@ice/core/validation/classifiers.ts and @ice/types/connection-rules/
predicates.ts) is deferred — @ice/core deliberately does NOT depend
on @ice/types, and crossing that boundary would be a larger
architectural change than rf-0c warrants. The classifiers.ts
docstring was strengthened to flag the drift-watch responsibility.

194 tests passing; 0 typecheck regressions.

* progress: mark 2026-05-02 batch (Step 1-4) complete

Move the six deferred follow-ups, the Q2 learnings.md compaction, the
merge-story decision, and the progress-housekeeping pass from In flight
to Done this week. In flight is now empty.

* core: add 'auth' high-level resource (Cognito / Firebase Auth / Entra ID)

Adds a new managed-auth resource to the security category. Maps…
julia-kafarska added a commit that referenced this pull request May 20, 2026
* state: add three learnings from P3 cohort 3 (rf-esp / rf-rval / rf-cload)

- ts2834-baseline-error-moves-with-the-import: extracting a TS2834
  baseline import into a deeper subdirectory relocates the error,
  doesn't introduce one.
- fs-existssync-is-non-configurable-under-vitest-esm: vi.spyOn fails
  on node:fs namespace imports; use chdir into tmpdir instead.
- one-source-of-truth-for-types-in-shim-refactors: when decomposing
  a class file, public types live in a sibling <name>-types.ts to
  avoid cycles between the shim and helper modules.

* rf-pimp-1: extract state parsing helpers from pulumi state-importer

Pulls six pure helpers out of state-importer.ts into a new
parsing.ts module:

  - get_deployment           — read PulumiDeployment from either state shape
  - get_stack_info           — derive {stack,project} from checkpoint or URN
  - extract_name_from_urn    — last-segment fallback when parse_urn fails
  - is_secret_value          — sentinel-UUID check for Pulumi secret wrappers
  - unwrap_secret            — ciphertext > plaintext > value resolver
  - create_empty_metadata    — unknown-sentinel PulumiImportMetadata for errors

state-importer.ts: 564 -> 481 LOC.

22 new tests in parsing.test.ts pin the byte-identical behaviour
(secret sentinel UUID, both state shapes, URN fallback path,
empty-metadata defaults). All 79 existing pulumi/terraform tests
still pass; TS2834 baseline (29) unchanged.

* rf-pimp-2: extract resource conversion from pulumi state-importer

Pulls the resource-shape conversion out of state-importer.ts into a
new resource-conversion.ts module:

  - process_properties — recursive property walker with secret unwrap
  - import_resource    — PulumiResource -> PulumiImportedResource

Both are pure (warnings array is mutated like before).  Behaviour
preserved verbatim:
  - URN parse + extract_name_from_urn fallback for name
  - outputs > inputs precedence (with NO_OUTPUTS warning fallback)
  - dependencies = explicit ++ parent (parent appended last, in order)
  - additional_secret_outputs mirrored verbatim
  - protect/external default false; id passes through

state-importer.ts: 481 -> 387 LOC.

18 new tests pin the secret-mask path, the warning emission path, the
parent-last dependency append, and the protect/external defaults.
86 total importer tests pass; TS2834 baseline (29) unchanged.

* rf-pimp-3: extract graph conversion from pulumi state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph — PulumiImportResult -> MutableGraph
  - import_pulumi_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer and
the existing 79 importer tests bind directly to it.

state-importer.ts: 387 -> 278 LOC (564 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/stack/project)
  - per-node labels (provider, pulumi_type, optional protected/external)
  - per-node provenance annotations (imported_from, pulumi_urn)
  - resource.id lifted to node properties.id
  - self-dependency and missing-target edges skipped
  - target_graph merge mode preserves nodes only (edges already dropped)

12 new tests cover the node properties/labels/annotations, the edge
filters (self-loop + missing target), and the graph metadata.  98
total importer tests pass; TS2834 baseline (29) unchanged.

* rf-timp-1: extract sensitive-attribute masking + empty metadata

Pulls three pure helpers out of state-importer.ts into a new
sensitive.ts module:

  - mask_sensitive_attributes — top-level walker over sensitive paths
  - mask_path                 — recursive leaf-mask mutator
  - create_empty_metadata     — unknown-sentinel ImportMetadata for errors

state-importer.ts: 547 -> 514 LOC.

15 new tests pin the path-tokenisation behaviour:
  - top-level / dotted / bracket-array path forms
  - non-object intermediates abort the walk (no error)
  - missing leaves and null intermediates are no-ops
  - empty path / empty-input early return

All 79 existing pulumi/terraform tests still pass; TS2834 baseline
(29) unchanged.

* rf-timp-2: extract resource conversion + dependency inference

Pulls three functions out of state-importer.ts into a new
resource-conversion.ts module:

  - import_resource_instance — (resource, instance) -> ImportedResource
  - infer_dependencies       — id/arn-driven dep inference post-pass
  - scan_for_references      — recursive walker over property tree

state-importer.ts: 496 -> 368 LOC.

Behaviour preserved verbatim:
  - address = [module.]type.name[index_key], JSON-encoded index_key
  - ICE name = name_prefix + name + (_index_key when present)
  - sensitive_attributes path masking + SENSITIVE_MASKED warning
  - explicit instance.dependencies pass-through
  - id_lookup indexed by both 'id' AND 'arn' property values
  - Set-seeded dedup union of explicit + inferred deps
  - dependencies[] mutated in place per resource

22 new tests cover address building (incl. module/index_key paths),
the masking warning emission, the include_sensitive=true bypass, and
all three sub-cases of scan_for_references (string/array/object/null).
65 total terraform tests pass; TS2834 baseline (29) unchanged.

* rf-timp-3: extract graph conversion from terraform state-importer

Pulls the two graph-emit functions out of state-importer.ts into a
new graph-conversion.ts module:

  - import_result_to_graph    — TerraformImportResult -> MutableGraph
  - import_terraform_to_graph — file-path -> MutableGraph wrapper

state-importer.ts re-exports both via barrel-style 'export {...} from'
to preserve the public surface — index.ts imports state-importer.

state-importer.ts: 368 -> 268 LOC (547 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source/version/lineage)
  - per-node labels (provider, terraform_type, optional module)
  - per-node provenance annotations (imported_from, terraform_address)
  - all edges tagged 'inferred: true' regardless of origin
  - missing-target edges silently skipped
  - target_graph merge mode preserves nodes only

10 new tests cover the node properties/labels/annotations, the
optional module label, and the missing-target edge filter. 75 total
terraform importer tests pass; TS2834 baseline (29) unchanged.

* rf-aimp-1: extract ARN/tag helpers from aws-importer

Pulls four pure helpers out of aws-importer.ts into a new
arn-helpers.ts module:

  - extract_name_from_arn   — trailing /-or-:-separated name
  - extract_account_from_arn — 5th segment, '' when malformed
  - extract_region_from_arn  — 4th segment, 'global' default for IAM/CF
  - parse_tags               — Tags-array OR tags-object normalisation

aws-importer.ts: 533 -> 495 LOC.

Behaviour preserved verbatim:
  - 6-segment ARN check, segment-5+ join for resource portion
  - resource fallback when split-on-/-or-: leaves empty trailing
  - global default for empty region slot (IAM, CloudFront)
  - Tags array preferred over tags object when both exist
  - String() coercion for non-string Key/Value pairs

22 new tests cover all four helpers across well-formed ARNs,
malformed inputs, IAM-style global resources, and both tag formats.
TS2834 baseline (29) unchanged.

* rf-aimp-2: extract AWS SDK init from aws-importer

Pulls the dynamic-import wrapper functions out of aws-importer.ts into
a new sdk-init.ts module:

  - AWSSdk interface  — STS / ResourceExplorer / ConfigService bundle
  - init_aws_sdk      — dynamic-import client-sts/-resource-explorer-2/
                        -config-service, optional fromIni({profile})
  - get_account_id    — STS GetCallerIdentity wrapper, 'unknown' on err

aws-importer.ts: 495 -> 444 LOC.

Behaviour preserved verbatim — including the load-bearing
'Function("m", "return import(m)")' pattern that prevents bundlers
from transpiling the dynamic import to a static require (which would
break the optional-dep guarantee for users who never use AWS).

5 new tests cover the friendly install-the-sdk error path and the
'unknown' fallback in get_account_id.  TS2834 baseline (29) unchanged.

* rf-aimp-3: extract resource discovery from aws-importer

Pulls the two paginated AWS-API discovery loops out of aws-importer.ts
into a new discovery.ts module:

  - map_resource_explorer_hit       — pure: hit -> AWSResource
  - map_config_result               — pure: JSON-string -> AWSResource|null
  - discover_with_resource_explorer — paginated SearchCommand wrapper
  - discover_with_config            — paginated SelectResourceConfig wrapper

Two pure mappers were extracted alongside the discover_*() loops to
make the response-shape -> AWSResource conversion testable without
needing to stub the dynamic @aws-sdk/client-* imports (the
'Function("m", "return import(m)")' indirection bypasses any
Vitest module registry).

aws-importer.ts: 438 -> 358 LOC.

Behaviour preserved verbatim:
  - Resource Explorer: QueryString='*', MaxResults=100, NextToken pagination
  - Config: SelectResourceConfigCommand SQL DSL (LIKE '%')
  - region default 'global' for both paths
  - resourceId preferred over ARN-derived name (Config only)
  - JSON.parse failures silently skipped (Config only)

8 new tests cover the pure mappers across well-formed / partial /
malformed inputs, plus the SDK-not-installed failure path on each
discover_*() entrypoint.  TS2834 baseline (29) unchanged.

* rf-aimp-4: extract graph conversion + relationship inference

Pulls the two graph functions out of aws-importer.ts into a new
graph-conversion.ts module:

  - aws_result_to_graph  — AWSImportResult -> MutableGraph
  - infer_relationships  — ARN-driven dep inference post-pass

aws-importer.ts re-exports aws_result_to_graph via barrel-style
'export {...} from' to preserve the public surface — index.ts and
the importers index both bind to it.  Local consumer
import_aws_to_graph aliases the local import as aws_result_to_graph_impl
to avoid same-name collision with the re-export.

aws-importer.ts: 346 -> 250 LOC (533 LOC at series start).

Behaviour preserved verbatim:
  - graph-level labels (source, account_id)
  - per-node labels: provider, aws_type, account_id, region, ...tags
    (tags spread last — tags WIN on key collision with canonical labels)
  - per-node provenance: imported_from, aws_arn, aws_account
  - depends_on edges with inferred:true + source:aws labels
  - self-dependency and missing-target edges silently skipped
  - infer_relationships REPLACES dependencies (not unions) — load-bearing
  - ARN matching gated by 'arn:aws:' prefix and arn_set membership

19 new tests cover the canonical-label-vs-tag collision, the
self-dep/missing-target edge filters, dedup of repeated references,
own-ARN exclusion, and the dependency-replacement (not -union)
contract.  148 total importer tests pass; TS2834 baseline (29)
unchanged.

* state: add two learnings from P3 cohort 4 (rf-aimp series)

dynamic-import-indirection-blocks-test-mocks
  The Function('m', 'return import(m)') pattern in aws-importer
  bypasses Vitest's module registry — vi.mock can't stub the AWS
  SDK calls. Workaround: extract pure mappers and test those.

same-name-local-import-and-reexport-collision
  When an extracted function is both consumed locally AND re-exported
  for the public surface, alias the local import to make the two
  roles explicit (X_impl vs X).

* rf-tfexp-1: extract terraform types module

Verbatim port of TerraformExportOptions / RequiredProvider /
TerraformResource / TerraformLifecycle / TerraformConfig /
TerraformBlock / TerraformProviderConfig / TerraformVariable /
TerraformOutput / TerraformExportResult shapes from
terraform-exporter.ts (pre-extraction L20-160) into a dedicated
types module. Public surface preserved — re-exported from the
orchestrator in the slim-down unit.

* rf-tfexp-2: extract terraform sanitize_name helper

Verbatim port of the private sanitizeName method from
terraform-exporter.ts (pre-extraction L420-428) into a pure
helper module. The Terraform identifier rules differ from
Pulumi's (underscore prefix vs r- prefix; preserves _ in
identifiers); kept separate to avoid coupling.

Tests: 10 cases covering alphanumeric pass-through, dot/slash/
space substitution, leading-digit prefix, unicode replacement.

* rf-tfexp-3: extract terraform fallback_type_mapping helper

Verbatim port of the private fallbackTypeMapping method from
terraform-exporter.ts (pre-extraction L335-362). Provider-prefix
table preserved exactly; gcp/aws/azure branch ordering preserved
(a gcp.* type always hits the gcp branch even if provider token
is non-gcp). Documented the pre-extraction quirks where the aws
and azure branches hard-code the prefix regardless of provider
token.

Tests: 17 cases covering each branch + provider mapping table.

* rf-tfexp-4: extract terraform value-transform helpers

Verbatim port of mapProperties / transformValue / formatDependencies
from terraform-exporter.ts (pre-extraction L367-418). Three pure
transformations with no class-state dependency.

Key differences vs Pulumi's value-transform: keys are preserved
AS-IS (Terraform uses snake_case natively); transform_value does
not rename nested keys. format_dependencies emits # placeholders
(pre-extraction had a TODO comment about lookup; preserved).

Tests: 23 cases covering null/undefined normalisation, _-prefix
filtering at top level only, recursive nested transforms, array
handling, dependency placeholder formatting.

* rf-tfexp-5: extract terraform HCL formatter helpers

Verbatim port of formatHCLValue / toHCL / toJSON from
terraform-exporter.ts (pre-extraction L433-545). Pure functions;
no class state.

The HCL output format is byte-identical to the pre-extraction
class methods. Particularly load-bearing:
 - String escape order (backslash first, then quote)
 - null/undefined property values SKIPPED (not emitted as null)
 - depends_on block omitted when empty
 - HCL object syntax 'key = value' (not JSON-style 'key: value')
 - Trailing blank line after each section

Tests: 37 cases covering all value types + full to_hcl snapshot
regression guard.

* rf-tfexp-6: extract terraform converter helpers

Verbatim port of buildDependencyMap / nodeToResource /
exportGraph from terraform-exporter.ts (pre-extraction L189-330).
The class state previously held by the orchestrator
(schema_provider) is now passed as the first argument to each
helper.

Pre-extraction quirks preserved:
 - depends_on edge filter (other relationships ignored)
 - node.properties || {} defensive default
 - unmapped_types deduped via Set; warnings NOT deduped
 - format-selection branch checks 'json' literal; everything
   else (including undefined) emits HCL

* rf-tfexp-7: slim terraform-exporter orchestrator (558 -> 102 LOC)

The class is now a thin orchestration shell:
 - constructor instantiates schema_provider
 - initialize() lazy-initialises the schema provider
 - exportGraph() delegates to ./terraform/converter.export_graph

Public API unchanged — TerraformExporter, create_terraform_exporter,
and the eleven exported types all keep their pre-extraction shape.
External consumers (export/index.ts) continue importing through
the orchestrator path; the new terraform/* modules are internal.

* rf-pmap-1: extract pulumi type-mapper data tables

Verbatim port of PROVIDER_MAP (~24 entries) and TYPE_MAP
(~280 entries) from importers/pulumi/type-mapper.ts
(pre-extraction L94-413). The TYPE_MAP entries are the
SOURCE OF TRUTH for ICE iceType names — external consumers
depend on the exact dotted-form values; preserved verbatim.

Size exception: 376 LOC justified by data-only nature
(cf. /docs/refactoring-patterns.md 'Data-heavy shim split').

Tests: 18 cases pinning provider count, key existence, ICE
type format, dedup behaviour for collapsed mappings (e.g.
aws:s3/bucket:Bucket and aws:s3/bucketV2:BucketV2 both
mapping to aws.s3.bucket).

* rf-pmap-2: extract pulumi type-mapper URN/type parsers

Verbatim port of parse_urn and parse_type from
importers/pulumi/type-mapper.ts (pre-extraction L19-86).
Pure string parsers; no data-table dependency.

Tests: 19 cases covering URN parts validation, special
type handling (pulumi:pulumi:Stack, pulumi:providers:*),
standard format vs alternative format priority order,
malformed input fallback to empty object.

* rf-pmap-3: extract pulumi type-mapper mapping helpers

Verbatim port of get_ice_type, get_ice_provider,
get_provider_from_type, is_type_supported,
get_supported_types, get_supported_ice_types,
get_name_from_urn, is_provider_resource, is_stack_resource
from importers/pulumi/type-mapper.ts (pre-extraction
L422-527). Plus the private to_snake_case helper
(pre-extraction L500-505).

Pre-extraction quirks preserved:
 - get_ice_type three-stage fallback (TYPE_MAP -> synth -> lowercase dotted)
 - get_ice_provider three-stage fallback (URN -> type -> simple-name -> 'unknown')
 - is_type_supported strict TYPE_MAP membership (synthesised paths excluded)
 - get_supported_ice_types deduped via Set
 - to_snake_case strips leading underscore from ([A-Z]) capture

Tests: 21 cases covering all eight helpers + fallback paths.

* rf-pmap-4: slim pulumi type-mapper orchestrator (527 -> 42 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the (implicit) data-table re-exports keep
their pre-extraction shapes. External consumers
(state-importer.ts, parsing.ts, resource-conversion.ts,
index.ts) continue importing through this shim path
without changes.

* rf-galg-1: extract graph topological-sort + cycle detection

Verbatim port of topological_sort, reverse_topological_sort,
has_cycle, find_cycles, find_cycle_in_subgraph from
graph/algorithms.ts (pre-extraction L18-220). Grouped together
because topological_sort uses find_cycle_in_subgraph (private)
for error reporting; has_cycle is a one-line wrapper around
topological_sort.

Pre-extraction quirks documented:
 - topological_sort double-counts in-degree decrement (iterates
   outgoing edges + ALL edges with target === current)
 - find_cycles returns DUPLICATE cycles when nodes participate
   in multiple cycles (no dedup)
 - find_cycle_in_subgraph returns first cycle OR up to 5 nodes
   from input on failure (best-effort error fallback)

Tests: 16 cases covering linear chains, cycles, disconnected
nodes, non-depends-on edge filtering. Plus shared fixtures.ts
with make_graph helper for the rest of the rf-galg series.

* rf-galg-2: extract graph path-finding helpers

Verbatim port of find_all_paths and find_shortest_path from
graph/algorithms.ts (pre-extraction L229-297). Independent
DFS/BFS path finding; no dependency on topo/cycle/components.

Pre-extraction quirks documented:
 - find_all_paths uses recursive DFS with visited-set cycle
   avoidance + max_paths cap (default 100)
 - find_shortest_path uses Array.shift BFS (O(n) per shift,
   not O(1)); preserved verbatim
 - find_shortest_path returns null on no path, [start] when
   start === end

Tests: 12 cases covering direct edges, diamonds, cycle
avoidance, max_paths cap, null vs empty array.

* rf-galg-3: extract connected components helpers

Verbatim port of find_connected_components and
find_strongly_connected_components from graph/algorithms.ts
(pre-extraction L307-402). Two distinct algorithms:
 - find_connected_components: BFS, treats edges as UNDIRECTED
   (uses both incoming and outgoing edges)
 - find_strongly_connected_components: Tarjan's, treats edges
   as DIRECTED; filters single-node SCCs (length > 1 only)

Pre-extraction quirks documented:
 - find_connected_components tolerates duplicate enqueue (the
   visited.has check is at top of inner loop, not at enqueue)
 - find_strongly_connected_components excludes singleton SCCs
   (intentional — singleton means trivially-strongly-connected,
   so excluding gives meaningful SCCs only)
 - Recursive Tarjan can hit JS stack limits on deep graphs

Tests: 12 cases covering empty graphs, disconnected nodes,
diamonds, mutual cycles, multi-cycle graphs.

* rf-galg-4: extract dependency analysis + graph metrics

Verbatim port of get_execution_layers, get_critical_path,
calculate_metrics, GraphMetrics interface from
graph/algorithms.ts (pre-extraction L412-586). Depends on
helpers from topo-cycle and components modules.

Pre-extraction quirks documented:
 - get_execution_layers uses iterative layer-peel; on cycle
   produces empty layer and breaks (silent ceasing)
 - get_critical_path KNOWN BUG: distance update walks
   incoming-edge predecessors, but topo order processes leaves
   first for depends_on graphs, so the source-distance lookup
   reads -Infinity and the chain never propagates. Effectively
   returns just the start (no-deps) node. Preserved verbatim
   — fixing this changes public behaviour, out-of-scope for
   refactor.
 - calculate_metrics density formula: e/(n*(n-1)); guards
   max_edges > 0 to avoid div-by-zero on empty graphs

Tests: 15 cases covering empty graphs, parallelisable layers,
cycle detection, density edge cases, degree statistics. Two
critical-path tests assert length >= 1 (preserving the
pre-extraction quirk).

* rf-galg-5: slim graph algorithms orchestrator (586 -> 51 LOC)

The file is now a pure re-export shim. All eleven exported
functions plus the GraphMetrics type keep their pre-extraction
shapes. External consumers (graph/index.ts,
plan/plan-engine.ts, graph/validator/validators.ts) continue
importing through this shim path without changes.

* rf-pmap rf-galg: append learnings on critical-path quirk + data-table LOC exception

* rf-cmove: split use-container-move.ts (564 → 189 LOC orchestrator)

Decomposes the rf-canv-25b useContainerMove hook into pure runners
under hooks/container-move/. Behavior preserved verbatim — both
handleNodeMove and handleToggleFold delegate to the new sub-modules.

  - container-move/types.ts (25) — PositionUpdate / SizeUpdate
  - container-move/ancestor-expansion.ts (275) — walkAncestorsAndExpand,
    expandAncestorOnce (shared by both handlers, parameterized over
    sibling-bounds reading strategy)
  - container-move/clamp.ts (114) — clampDraggedNodeToParent +
    detectExitingGroupId (tri-state setter directive)
  - container-move/move-runner.ts (127) — runNodeMove pure runner
  - container-move/toggle-fold-runner.ts (187) — resolveToggleFoldDecision
    + runUnfoldExpansion pair

Tests: 27 baseline (use-container-move.test.tsx) + 68 new sub-module
tests = 95 total passing. Coverage on container-move/: 100% statements
/ 100% branches / 100% functions / 100% lines (types.ts excluded as
runtime-empty).

Per blueprint risk #2 — DO NOT consolidate the four ancestor-expansion
sites with rf-canv-4's expandToFitChildren; they have subtly different
rules. The split preserves the per-handler bbox-reading strategy via
the siblingPosLookup + siblingBoundsOverride params on
walkAncestorsAndExpand.

* rf-vval: split validators.ts (524 → 68 LOC orchestrator)

Decomposes the built-in validators into domain-grouped modules under
graph/validator/validators/. Public API and behavior preserved verbatim
— the orchestrator file becomes a thin re-export shim plus the two
factory functions.

  - validators/structure.ts (163) — Cycle, Reference, Naming,
    Connectivity (graph topology + names, no schema dep)
  - validators/schema.ts (195) — Type, Property (schema-provider deps)
  - validators/security.ts (138) — SensitiveData, BestPractices
  - validators.ts (68) — re-exports + create_builtin_validators +
    create_configured_validator factories

Tests: 3 baseline (core.test.ts Graph Validator suite) + 70 new
sub-module tests = 73 total passing. Coverage on
validators/{structure,schema,security}.ts + validators.ts: 100%
statements / 100% branches / 100% functions / 100% lines.

The public exports from packages/core/src/index.ts (CycleValidator,
ReferenceValidator, ..., create_builtin_validators,
create_configured_validator) remain wired through the orchestrator
shim, so external consumers and the dist barrel don't change.

* rf-ierr: split import-errors.ts (507 → 30 LOC orchestrator)

Decomposes the import error classification system into per-cloud
modules under errors/import-errors/. Behavior + every error message
string preserved verbatim (user-facing strings are stable).

  - import-errors/types.ts (117) — ImportErrorCode enum,
    ImportErrorAction, ImportError, ImportWarning, ImportErrorActionType
  - import-errors/gcp.ts (145) — classifyGCPError
  - import-errors/aws.ts (144) — classifyAWSError
  - import-errors/azure.ts (119) — classifyAzureError
  - import-errors.ts (30) — re-export shim, public API unchanged

Tests: 0 baseline + 87 new sub-module + shim tests = 87 total. Coverage
on import-errors/{types,gcp,aws,azure}.ts: 100% statements / 100%
branches / 100% functions / 100% lines. The shim file shows 0% in v8
coverage (re-only-export quirk; the named imports through it are
exercised by the dedicated shim test file).

External consumers (aws-importer.ts, azure-importer.ts,
gcp/services/asset-inventory.ts, errors/index.ts barrel) keep their
original `from '../../errors/import-errors.js'` import paths.

Full core suite: 2472 tests still pass.

* rf-cmove: append learning on tri-state setter directive pattern

* Phase 3 complete: all 18 files in 500-600 LOC band refactored

64 commits across 6 cohorts. 9756 → 3441 LOC orchestrators (-65%).
+1356 tests added. 3 pre-existing bugs surfaced and documented
(preserved verbatim per refactor discipline).

Remaining files over 500 LOC are documented exceptions only:
- Generated (resource-types.ts)
- Data-heavy (high-level-resources, scale-presets-data, cloud-blocks-data,
  themes, ast/types)
- Refactored orchestrators with residual cohesion (deploy.service,
  svg-canvas, system-prompt)

The actionable refactor queue is empty.

* rf-deploy2-1: extract planDeployment + fallbackPlan to plan-deployment.ts

Pulls planDeployment (~85 LOC) and its module-private fallbackPlan
(~44 LOC) out of deploy.service.ts into a dedicated module. The
orchestrator re-exports planDeployment so the namespace import in
routes/canvas-deploy.ts and the export * in services/deploy/src/index.ts
keep resolving unchanged.

deploy.service.ts: 1572 → 1449 LOC.
Adds plan-deployment.test.ts covering happy path + translator-throw fallback.

* rf-deploy2-2: extract applyDeployment to apply-deployment.ts

Pulls applyDeployment (~573 LOC) — the 5-phase translate/auth/deploy
pipeline — out of deploy.service.ts into a dedicated module. The
orchestrator re-exports applyDeployment so the namespace import in
routes/canvas-deploy.ts and the named import in queue.service.ts
continue to resolve unchanged.

deploy.service.ts: 1449 → 894 LOC.
apply-deployment.ts: 600 LOC (slightly over 500-line ceiling; flagged
for follow-up internal split per brief).

* rf-deploy2-3: extract destroyAllForCard to destroy-all-for-card.ts

Pulls destroyAllForCard (~242 LOC) — the 'nuke' path that destroys every
ICE-managed resource for a card across all historical deployments — out
of deploy.service.ts into a dedicated module. The orchestrator re-exports
destroyAllForCard so the namespace import in routes/canvas-deploy.ts
keeps resolving unchanged.

deploy.service.ts: 894 → 644 LOC.
destroy-all-for-card.ts: 281 LOC.

* rf-deploy2-4: extract destroyDeployment to destroy-deployment.ts

Pulls destroyDeployment (~338 LOC) — the latest-apply-baseline destroy
path with reverse-order delete loop and pdl-10 per-resource node_status
emit — out of deploy.service.ts into a dedicated module. The orchestrator
re-exports destroyDeployment so the namespace import in
routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 644 → 311 LOC.
destroy-deployment.ts: 364 LOC.

* rf-deploy2-5: extract rollbackDeployment to rollback-deployment.ts

Pulls rollbackDeployment (~187 LOC) — the rebuild-from-target-deployment
diff/deploy path — out of deploy.service.ts into a dedicated module.
The orchestrator re-exports rollbackDeployment so the namespace import
in routes/canvas-deploy.ts keeps resolving unchanged.

deploy.service.ts: 311 → 129 LOC.
rollback-deployment.ts: 210 LOC.

* rf-deploy2-6: orchestrator slim-down — drop unused imports + docstring

Trims now-unused imports from deploy.service.ts (acquireDeployLock,
DeployLockError, providerService, getResourceMap, etc.) and updates the
top-of-file docstring to describe the file's new role: a thin re-export
shim plus 5 small DB-only helpers and the snapshot-persister side-effect
init. Body is unchanged behaviorally.

deploy.service.ts: 129 → 107 LOC (1572 → 107 across the rf-deploy2 series,
93% reduction). Public API surface preserved: every symbol exported
pre-refactor still resolves through the orchestrator (planDeployment,
applyDeployment, destroyAllForCard, destroyDeployment, rollbackDeployment,
getDeploymentStatus, getDeployedResources, getDeploymentHistory,
requestDeployCancel, getCurrentDeploySnapshot, DeployProgressSnapshot,
getNodeDeploymentOverlay, checkDrift).

* rf-deploy2-2 housekeeping: split apply-deployment.ts under 500 LOC

apply-deployment.ts landed at 600 LOC after rf-deploy2-2 (just over the
project's 500 LOC ceiling). This follow-up extracts five mechanically-
separable helpers into apply-pipeline-helpers.ts, bringing the orchestrator
under the ceiling without changing the apply pipeline's behavior:

- logSourceRepoDiagnostics: pre-deploy canvas-shape diagnostic log lines
- ensureAutoDeployRules: best-effort Source.Repository → Compute auto-rule writer
- logDiffForDebugging: console-only desired-vs-current node dump
- normalizeIdempotentResultErrors: NOT_FOUND/ALREADY_EXISTS rewrites
- persistResourceMappings: post-deploy mapping-table writes (in-place mutation)

apply-deployment.ts: 600 → 493 LOC.
apply-pipeline-helpers.ts: 220 LOC.
Adds 11 tests covering the three helper shapes that benefit most from
isolated test coverage.

* rf-deploy2: append awk-line-range learning from rf-deploy2-2

* rf-hlres-1: extract HighLevel* type interfaces to high-level-resources/types.ts

Pulls the 5 type interfaces (ProviderImplementation, HighLevelResource,
OptionDetail, HighLevelProperty, HighLevelCategory) plus the NodeBehavior
re-export out of the orchestrator. The shim now re-exports each name
verbatim, preserving the public API for both packages/core/src/resources/index.ts
and packages/core/src/index.ts.

Smoke test pins:
- the types module is loadable as a namespace
- structurally-typed values for each of the 5 interfaces compile
- the shim re-exports the 5 type names + NodeBehavior
- HIGH_LEVEL_CATEGORIES still has the 7 canonical category ids in order

Baseline typecheck preserved at 29 TS2834 errors (all in unrelated barrel
files); no new errors in touched paths.

* rf-hlres-2: extract compute category to high-level-resources/categories/compute.ts

Cuts the 'compute' object literal (~1779 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES and re-imports it as a const. The orchestrator
shrinks from 6354 to 4577 LOC.

Size exception documented in the file header — the file is dominated by
data (per-resource property catalogues, instance-size pickers, provider
implementations) and would only fragment further without improving
readability.

Smoke test pins the 13 canonical resource ids in order plus the shape
of frontend-app and backend-api. Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-3: extract database category to high-level-resources/categories/database.ts

Cuts the 'database' object literal (~2167 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 4577 to 2411 LOC.

Size exception documented in the file header — same shape as compute.

Smoke test pins the 13 canonical database resource ids in order
(postgres-db through search-engine) plus the shape of postgres-db
and redis-cache. Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-4: extract storage category to high-level-resources/categories/storage.ts

Cuts the 'storage' object literal (~449 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 2411 to 1964 LOC.

Smoke test pins the 5 canonical storage resource ids in order
(object-storage, oss, oci-object-storage, do-spaces, file-storage).
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-5: extract networking category to high-level-resources/categories/networking.ts

Cuts the 'networking' object literal (~559 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1964 to 1407 LOC.

Smoke test pins the 7 canonical networking resource ids in order
(public-endpoint, vpc-network, subnet, load-balancer, cdn, api-gateway,
dns-zone). Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-6: extract messaging category to high-level-resources/categories/messaging.ts

Cuts the 'messaging' object literal (~842 LOC) verbatim out of
HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 1407 to 567 LOC.

Smoke test pins the 7 canonical messaging resource ids in order
(message-queue, event-bus, rabbitmq, cloud-pubsub, service-bus,
email-service, event-stream) and that message-queue carries the deep
optionDetails arrays used by the AWS / GCP / Azure queue-type picker.
Baseline typecheck preserved at 29 TS2834 errors.

* rf-hlres-7: extract security + monitoring categories (combined unit)

Cuts the last two object literals (security ~185 LOC, monitoring ~185 LOC)
verbatim out of HIGH_LEVEL_CATEGORIES. The orchestrator shrinks from 567
to 200 LOC — 7 categories now imported, no inline literals remain in
HIGH_LEVEL_CATEGORIES.

Both files are well under the 500 LOC ceiling; split out for symmetry
with the rest of the categories sub-tree.

Smoke tests pin the canonical resource ids in order: security
(secret-store, ssl-certificate, service-account) and monitoring
(log-group, alert, dashboard). Baseline typecheck preserved at 29
TS2834 errors.

* rf-hlres-8: extract helpers + HIGH_LEVEL_CATEGORIES to high-level-resources/helpers.ts

Moves the 7 helper functions (getAllHighLevelResources, palette projection,
filterResourcesByProvider, getBehaviorLabel/Color, getGCPCloudAssetTypes,
cloudAssetToHighLevelType) plus the PULUMI_TO_CLOUD_ASSET map and the
HIGH_LEVEL_CATEGORIES assembly into a new helpers module.

helpers.ts owns the runtime origin of HIGH_LEVEL_CATEGORIES because the
cloud-asset helpers iterate over it — making the helpers the runtime owner
avoids a 'helpers → orchestrator → helpers' cycle. The orchestrator now
re-exports each runtime symbol verbatim so external consumers
(packages/core/src/index.ts, packages/core/src/resources/index.ts,
schema-bridge, property-rules, gcp asset-inventory, type-mapper) keep
the same import surface.

Exhaustive tests pin: HIGH_LEVEL_CATEGORIES order, getAll flatMap +
ordering, palette projection (ice_type / display_name / category /
behavior / providers / implementations / properties), provider filter
+ 'all' bypass + unknown provider returns empty, behavior label/color
delegation, GCP asset-types deduplication, and cloudAsset→HL reverse
lookup including null on unknown.

Orchestrator file: 202 → 37 LOC. Helpers: 195 LOC. Baseline typecheck
preserved at 29 TS2834 errors. Full @ice/core suite (2515 tests) passes.

* rf-hlres-9: orchestrator slim-down — finalize the high-level-resources shim

Polishes the orchestrator after rf-hlres-8 left it as a 37-LOC re-export
shim. Drops the in-progress wording from the docstring, merges the two
`export type {}` blocks into one, and adds section dividers so future
readers can spot the type re-exports vs. the runtime re-exports at a
glance.

Final file size: 47 LOC. Net delta on the rf-hlres series:
  - high-level-resources.ts: 6434 → 47 LOC (a ~99.3% shrink)
  - new files: types.ts, helpers.ts, 7 categories/<name>.ts
  - public API surface unchanged (every name still resolves on the shim)

Repo-wide test run after the slim-down: 8144 tests pass. Baseline
@ice/core typecheck preserved at 29 TS2834 errors (all unrelated).

* rf-hlres: append learning on helpers-owns-assembled-array pattern + awk per-splice tactics

* rf-svgcv2-1: extract pan/zoom transform group as CanvasContent

The inner <g transform=...> body — grid, selection frame, both connection
layers, clipPaths, nodes layer, connection-drawing preview, user-traffic
overlay, and ghost overlay — moves into canvas-renderer/canvas-content.tsx.
Visual draw order, prop flow, and gating logic preserved verbatim.

svg-canvas: 570 → 490 LOC.
canvas-content: 236 LOC (new).

Tests: 10 passing — direct-FC tree-walker matching mocks by reference.
Typecheck clean.

* rf-svgcv2-2: extract SVG mouse-event routing as useCanvasMouseRouting

The four inline arrow handlers on the <svg> element — onMouseDown
(connection-port classList sniff + bindCanvas fall-through), onMouseMove
(drawingConnection branch + bindCanvas fall-through), onMouseUp (same
shape), and onMouseLeave (tooltip dismiss + bindCanvas) — collapse into
a hook that returns the spread-able handler bundle.

Behavior preserved verbatim: classList sniff stays orchestrator-side (per
RISK #5), tooltip dismissal still fires on every onMouseDown/onMouseLeave,
onAuxClick / onContextMenu pass through bindCanvas references unchanged.

svg-canvas: 490 → 473 LOC.
use-canvas-mouse-routing: 114 LOC (new).

Tests: 12 passing — covers all four routing seams + identity-pass
through for AuxClick/ContextMenu. Typecheck clean.

* rf-svgcv2-3: extract selection-dispatch + snapToGrid wiring as useCanvasInteractionsBindings

The orchestrator's useCanvasInteractions call carried three inline
arrow-function callbacks (onSelect / onToggleSelect / onBoxSelect)
plus the snapToGrid → gridSize ternary. Both groups are now bundled in
a thin wrapper hook that calls useCanvasInteractions internally.

Behavior preserved verbatim — the same dispatch sequences fire for each
selection-change branch, and snapToGrid still toggles between GRID_SIZE
and 0. The wrapper drops the snapToGrid prop from the inner args object
so useCanvasInteractions sees only its own keys.

svg-canvas: 473 → 457 LOC.
use-canvas-interactions-bindings: 69 LOC (new).

Tests: 8 passing — covers each dispatch callback, the gridSize ternary,
and the snapToGrid prop-drop. Typecheck clean. Full canvas-tests suite:
207/207 passing.

* rf-svgcv2-4: extract RenderCtx assembly as useRenderCtx hook

The eighteen-field renderCtx object the per-node renderer dispatch
consumes — sortedNodes, lod, pipelineNodeStatus, drag/exit highlight
ids, the seven event-handler callbacks, etc. — moves into a
useRenderCtx hook. The hook also binds getConnectedPipelineStatuses
to (card, pipelineNodeStatus) so renderers call it as a single-arg
function (preserving rf-canv2-5's surface).

Behavior preserved verbatim: the returned ctx contains every field
the original literal had, with the same refs. card and zoom move
into the hook's args bundle; svg-canvas no longer imports
getConnectedPipelineStatuses or the RenderCtx type.

svg-canvas: 457 → 453 LOC.
use-render-ctx: 105 LOC (new).

Tests: 5 passing. Bonus: rf-svgcv2-3 test JSX-form fix (Provider needs
explicit children prop in TS strict — convert createElement(Provider,
{store}, child) to JSX form). Typecheck clean.

* rf-spr2-1: split system prompt into composable section builders

The 350-line static prose body of buildSystemPrompt's template literal
moves into seven section-builder functions in system-prompt-sections.ts:
  - buildHeaderPrompt(dominantProvider)
  - buildIntentRoutingPrompt() — WHEN TO ACT vs WHEN TO ASK
  - buildOperationsPrompt(availableBlockTypes) — STRICT BLOCK REGISTRY
  - buildPropertyPrefillPrompt() — PROPERTY PRE-FILL RULES
  - buildOptimizationGuidelinesPrompt() — improve security/cost/HA/...
  - buildCanvasContextPrompt(nodes/edges/selected/schema) — Current Canvas
  - buildContainerNetworkingPrompt(connectionPrompt) — VPC + WIRING

The orchestrator concatenates them via the + operator. Output is
byte-identical to the pre-rf-spr2 prompt — verified by a new snapshot
test (3 fixtures: bare canvas, cloud-architect skill, question intent
with deployment context). Pure helpers (formatNodesSummary,
detectDominantProvider, buildCloudArchitectPrompt) stay in
system-prompt.ts.

system-prompt.ts: 516 → 203 LOC.
system-prompt-sections.ts: 420 LOC (new, prose-heavy — header documents
the data-table exception).

Tests: 33 passing (30 existing + 3 new snapshot fixtures).
Typecheck clean.

* rf-asttyp-1: split AST types by category, keep ast/types.ts as re-export shim

The 581-LOC types.ts split into four sub-files by AST node category:
  - types/base.ts        — AstNode + AstNodeKind union (65 LOC)
  - types/expressions.ts — Expression + literals + access + splat +
                            for-comprehension types (253 LOC)
  - types/blocks.ts      — Block + Attribute + NestedBlock (44 LOC)
  - types/statements.ts  — Program + Statement union + every top-level
                            block + LifecycleConfig + ValidationRule +
                            TypeExpression (268 LOC)

ast/types.ts becomes a 70-LOC type-only re-export shim. Every existing
consumer (the parent ast.ts shim, ast-helpers.test.ts, plus the broader
parser/visit_ast machinery) keeps importing from '../ast/types.js'
unchanged.

Tests: 15 type-shape tests added in __tests__/types-shim.test.ts —
covers every kind discriminator + the optional-field shapes for
ResourceBlock/LifecycleConfig/TypeExpression. Full parser test suite:
297/297 passing. Typecheck: no new errors (existing 29 TS2834 baseline
errors unaffected per the ts2834-baseline learning).

* rf-svgcv2-4 followup: fix RenderCtx → Record cast in test (TS2352)

Two-step cast (as unknown as Record) to satisfy strict overlap check —
RenderCtx has no index signature so the direct cast fails.

* rf-svgcv2 + rf-spr2-1 + rf-asttyp-1: capture three new learnings

- byte-identity-snapshot-must-be-captured-pre-refactor-not-post (rf-spr2-1)
- svg-canvas-orchestrator-loc-budget-flexible-with-renderctx-bundling (rf-svgcv2-4)
- render-ctx-record-cast-needs-double-as-unknown-as-record (rf-svgcv2-4)

* rf-spdat: split scale-presets-data 1482 LOC into 7 category files + 46-LOC orchestrator

Extract SCALE_PRESETS dict by domain category (compute, database, storage,
networking, messaging, security, monitoring) under scale-presets-data/.
Orchestrator becomes a 46-LOC re-export shim that spreads each per-category
record into the SCALE_PRESETS dict — order preserved.

Per-category sizes (LOC): compute 387, database 500, messaging 265,
networking 166, storage 142, security 30, monitoring 30. All categories
data-only (size-exception applies).

Smoke test (scale-presets-data.test.ts): 14 tests pinning category counts,
no cross-category collisions, assembled-dict equals union, byte-identical
sample lookups for compute/database/monitoring tiers.

* rf-cbdat: split cloud-blocks-data 1009 LOC into 9 category files + 126-LOC orchestrator

Extract the 16 BlockTemplate entries by their declared `category:` field
under cloud-blocks-data/ (frontend, backend, compute, data, storage,
networking, messaging, observability, security). Orchestrator assembles
BLOCK_TEMPLATES in the documented original order and derives BLOCK_CATEGORIES
(palette grouping) by category-field filter.

Per-category sizes (LOC): backend 210, data 192, networking 128, messaging
106, security 106, frontend 79, storage 72, compute 70, observability 65.

Smoke test (cloud-blocks-data.test.ts): 17 tests pinning per-category
counts, no cross-category template-name collisions, byte-stable assembled
ordering vs. the original file, BLOCK_CATEGORIES references the assembled
list (filter, not clone).

* rf-thmdat: split themes 590 LOC into 3 4-theme groups + 30-LOC orchestrator

Extract the 12 ColorTheme entries into 3 group files (4 themes each) by
the original ordering: group-1 default/retro/cupcake/valentine (warm),
group-2 synthwave/coffee/luxury/aqua (bold), group-3 forest/sage/dracula/
night (nature/dark). Orchestrator becomes a 30-LOC re-export shim that
spreads the three groups into the public T array.

Per-group sizes: 200/201/201 LOC — each well within the 200-500 ceiling.

Smoke test (themes-data.test.ts): 12 tests pinning per-group counts and
ordering, no cross-group id collisions, byte-stable assembled T array,
reference-equality preserved through the spread (orchestrator FC depends
on T[i] identity for the shallow click-handler closures).

* Final round complete: all exceptions decomposed (excluding generated files)

8 exception files split: deploy.service, high-level-resources, svg-canvas,
system-prompt, ast/types, scale-presets-data, cloud-blocks-data, themes.

12,754 → 1,082 LOC across orchestrators (-91%). 28 commits, +180 tests.

Files over 500 LOC remaining: only the generated resource-types.ts
(excluded per user) and 4 high-level-resources category sub-modules
(pure resource-definition data with SIZE EXCEPTION headers; further
fragmentation would split related resources without maintainability
gain).

* bugfix-2: lazy require.resolve in get_base_db_path

The pre-fix code eagerly evaluated
`require.resolve('@ice-engine/schemas/data/ice-schemas.db')` while
constructing the candidate array, so the function threw before
reaching the existsSync loop in any environment where that package
isn't installed (test envs, fresh checkouts, this monorepo's dev
setup). Wrap each candidate in a thunk and the require.resolve
fallback in its own try/catch so a missing package degrades to
"skip this candidate" instead of crashing the whole call.

Tests:
- Confirm get_base_db_path() does not throw without the package.
- Confirm it returns a string ending in ice-schemas.db.
- Confirm it returns the dev path when staged at the resolved location
  (priority over the missing-package fallback).

Discovered during rf-cload-3 (extraction of `get_base_db_path` from
`customization-loader.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* bugfix-3: get_critical_path now propagates distance through chain

Pre-fix the distance update walked `get_incoming_edges` and read
the *source's* distance, but `topological_sort` emits leaves first
for `depends_on` graphs — so source nodes are processed AFTER the
current node and their distance is always -Infinity at lookup
time. The chain never propagated past the start node and the
function returned `[start]` for any DAG.

Fix: walk `get_outgoing_edges` (the current node's dependencies)
and read the *target's* distance. Targets are processed earlier
in topo order (leaves first), so the lookup is always populated.

Tests:
- 3-node chain a→b→c → returns [c, b, a] (was [c]).
- 4-node chain a→b→c→d → returns [d, c, b, a].
- Diamond DAG (a→b, a→c, b→d, c→d) → 3-node longest path.
- Isolated node → returns [solo].
- Empty graph → [].
- Cyclic graph → [].
- Disconnected components → returns longest among them.
- Two pre-existing softened tests (`expect(path.length)
  .toBeGreaterThanOrEqual(1)`) removed; replaced by strict
  assertions that pin the corrected length and node order.

Path orientation: leaf-first → root-last, matching the natural
"start → end" reading of a critical path. For chain a→b→c the
"start" is c (no deps) and the "end" is a (most-dependent).

Discovered during rf-galg-4 (extraction from `graph/algorithms.ts`);
preserved verbatim then per the verbatim-preservation rule for
refactors. Now fixed as a behaviour-change PR per the rule that
pre-existing bugs documented in learnings get separate fix
tickets.

* bugfix-4: detectJsFramework now reads pnpm/yarn lockfile signals

Pre-fix `filesToCheck` had only 6 entries — `package.json`,
`Dockerfile`, `requirements.txt`, `go.mod`, `pom.xml`, `Cargo.toml`.
The detector iterated these and pushed found names into
`detectedFiles`, but the JS-ecosystem branch in `detectJsFramework`
checked `detectedFiles.includes('pnpm-lock.yaml')` /
`yarn.lock` — neither of which was in `filesToCheck`. So the
package-manager guess always fell through to npm regardless of
which lockfile the repo actually had.

Fix: add the three JS lockfiles to `filesToCheck` so they show up
in `detectedFiles` when present. Three extra Contents-API GETs per
detection call; cheap given GitHub's rate limits and one-shot
nature of this path.

Tests:
- pnpm-lock.yaml present → packageManager='pnpm',
  installCommand='pnpm install --frozen-lockfile'.
- yarn.lock present → packageManager='yarn',
  installCommand='yarn install --frozen-lockfile'.
- package-lock.json present → packageManager='npm' (default fall-
  through preserved; ladder has no explicit branch for it).
- No lockfile → packageManager='npm' (still works).
- pnpm + yarn both present → pnpm wins (ladder priority pinned).
- Removed the test that pinned the broken behavior ("package.json
  defaults to npm package manager (lock files are not in
  detectedFiles list)") — replaced by the corrected
  pnpm/yarn/no-lockfile triplet.

Bundles the SHA fixup for the bugfix-3 `_Fixed:_` line in
state/learnings.md.

Discovered during rf-pipe-6 (extraction of `framework-detection.ts`
from `pipeline.service.ts`); preserved verbatim then per the
verbatim-preservation rule for refactors. Now fixed as a
behaviour-change PR per the rule that pre-existing bugs documented
in learnings get separate fix tickets.

* learnings: append bugfix-2/3/4 sweep gotcha (Fixed-line audit trail)

Capture the rule of thumb that emerged from the bugfix sweep: only
the rf-galg-4 quirk had a dedicated learning anchor; the other two
bugs were documented inline in their SUT header comments. The
brief assumed every bug had its own anchor for the `_Fixed:_`
audit trail; reality is sparser and the implementer should pick
between inline-doc and learning-anchor based on whether the
deferred fix is a real future ticket.

* progress: archive entry for completed LOC discipline initiative

Multi-day refactor work (2026-04-29 → 2026-05-02) marked complete in
the Archive section of progress.md. Cross-references to phase totals,
bug fixes, and the new /docs/refactoring-patterns.md doc.

Cumulative: ~470 commits, ~7500 new tests, 73 files refactored,
4 latent bugs fixed, 1 generated file excluded, 4 data-leaf
exceptions remaining (documented).

Codebase decomposition is complete. Pausing here.

* progress: housekeep In flight, move pdl + rf-* detail trail to Archive

Reduces progress.md from 322 → 216 lines. In flight section now contains
just the 6 deferred follow-ups (pdl-11, rollupPercentage, nodesById warm-
seed, dead snapshot fields, data.status fallback, 3 rf-0c dedups). All
pdl-1..10 and rf-deploy/rf-props/rf-canv/rf-pdpl/rf-ctrans/rf-cards/
rf-fbh/rf-parse subsections moved verbatim under Archive so anchor/commit
references remain searchable.

* decisions: 2026-05-02 merge-story entry for refactoring branch

Document the choice to merge the 509-commit refactoring branch as a
single PR rather than splitting per phase. Rationale and alternatives
captured in the decision entry; consequences note that deferred follow-
ups (pdl-11 etc.) ship as separate PRs after the merge.

* fix(test): cast source_node_id read in apply-pipeline-helpers test

The helper mutates result.resources[i].source_node_id at runtime via
res: any. The test fixture's inline literal infers a strict type
without source_node_id, so the post-call assertion fails TS2834.
Add { source_node_id?: string } cast at the two assertion sites to
match the runtime mutation contract.

* state: archive pre-compaction snapshot of learnings.md (2026-Q2)

Verbatim copy of learnings.md before the Q2-2026 compaction pass.
Per CLAUDE.md schedule: cluster duplicates once per quarter, archive
the pre-compaction file as state/archive/learnings-YYYY-Qn.md, then
write the compacted version back. The compacted version lands in the
next commit.

204 anchors / 1676 lines preserved here for reference.

* state: compact learnings.md (Q2-2026 pass)

Cluster duplicates and trim redundancy after the parallel-deploy +
LOC-discipline initiatives. 204 → 113 anchors (-44%), 1676 → 780
lines (-53%). All 25 must-preserve anchors retained verbatim (the 24
referenced from /docs/refactoring-patterns.md plus the read-state-first
anchor cited in decisions.md and CLAUDE.md). Both _Promoted to:_ and
_Fixed:_ trailers preserved verbatim.

Representative cluster merges (canonical ← folded):
- ux-log-terminal-pitfalls ← 5 ux-log-* siblings
- ux-deploy-real-cloud-pitfalls ← 3 ux-deploy-* siblings
- pdl-7-wire-contract-trims-downstream-ui ← 3 pdl-7 follow-ups
- pdl-10-destroy-snapshot-and-dedup-traps ← 3 pdl-10 follow-ups
- ux-pdl-smoke-test-pitfalls ← 3 ux-pdl-* siblings
- inline-classification-duplications-are-not-actually-duplicates
  ← 4 sibling rf-canv anchors about inverted tie-breaks
- test-helper-defaults-traps-coalesce-and-spread ← 3 helper-default traps
- brief-numerics-are-approximate-source-is-canonical ← absorbed 3 brief-vs-source variants

Pre-compaction snapshot archived in the prior commit at
state/archive/learnings-2026-Q2.md.

* pdl-11: default node.data.provider to active deploy provider on drop

Closes the deferred follow-up from the parallel-deploy initiative
recorded in state/progress.md In flight. Palette drops without an
explicit `application/ice-block-provider` key (or with the 'all'
sentinel) now fall back to `state.deploy.provider`, so the deploy
panel doesn't filter newly-dropped blocks as "skipped — non-<provider>"
when the toolbar provider is set.

Three drop branches:
- Group drop: unchanged (groups have no provider).
- Block drop: paletteProvider 'all' or unset → effectiveProvider
  becomes deployProvider; threaded into both getBlueprint and
  expandBlueprint. Explicit palette providers (aws/azure) still win.
- Resource drop: newNodeData.provider is set to deployProvider.

logBlueprint still records the *palette* provider (analytics tracks
user intent, not the post-fallback value).

Tests: 21 → 23 in use-canvas-drop.test.tsx (+ 2 pdl-11 cases:
fallback-when-palette-omits, palette-wins-over-deploy-provider). One
existing test assertion updated from `provider: 'all'` to `provider:
'gcp'` to match the new behavior. Resource-drop case asserts
`data.provider === 'gcp'` (default deploy provider in the test store).

* deploy: extract deriveRollupPercentage helper from 3 inline copies

Closes pdl-5 critic findings #2 and #4 (deferred to follow-up). The
cap-at-99 progress formula was inlined identically in three places:
  - features/deploy/components/deploy-in-flight-panel.tsx:50
  - features/canvas/components/deploy-banner.tsx:94
  - shared/components/status-bar.tsx:216

Extracted to deploy/derive.ts next to deriveRollup as a pure projection
from DeployRollup → 0..100 percentage. Re-exported from deploy-slice
alongside deriveRollup / orderNodesForPanel so existing import paths
keep resolving.

Tests: 5 new dedicated cases for deriveRollupPercentage (empty / full /
cap-at-99 boundary / rounding / defensive zero-total). The
deploy-in-flight-panel.test mock switched to importOriginal +
selective override so deriveRollupPercentage runs real (it's a pure
projection from the rollup the test already controls via
mocks.deriveRollup) — saves maintaining a parallel mock that has to
mirror the cap formula.

19 → 19 derive tests (+5), 53 → 53 deploy-in-flight tests (refactor
preserves behavior), 0 typecheck regressions.

* deploy: warm-seed nodesById from snapshot in Phase 2 (pdl-5 #7)

Closes pdl-5 critic finding #7 (deferred to follow-up). When a tab
joins mid-deploy, Phase 2 of useDeploySubscription pulls the persisted
snapshot from the gateway and was previously only mirroring the
overlay onto the canvas blocks. The deploy panel's per-row list,
which derives from `nodesById`, stayed empty until Phase 2.5's replay
loop completed — leaving a brief "Preparing…" sentinel window even
though the per-block overlays already showed the right colors.

The warm-seed dispatches synthetic `node_status` events (and
`node_progress` when the snapshot carries a step descriptor) for each
node in `snapshot.nodeStatuses`, so the panel's per-row list renders
immediately.

Synthetic events use seq=0 so any live event (or replayed event from
Phase 2.5) with seq>0 dedup-wins on the same node, overwriting the
warm-seed entry's empty resource_name / resource_type / action with
the authoritative wire values.

New helper: overlayToWireStatus inverts mapWireStatusToOverlay
(snapshot stores the post-mapping overlay string; warm-seed walks it
back to a wire DeployNodeStatus). Returns null for overlay strings
that don't round-trip — e.g. 'destroying' / 'gone' from pre-pdl-10
destroy paths — so warm-seed skips them and Phase 2.5 fills them in
from the event tape.

Tests: 14 → 17 in use-deploy-subscription.test.ts (+3 for
overlayToWireStatus: full inverse map, null fallback for unknown
strings, round-trip through mapWireStatusToOverlay).

* deploy: drop DeployProgressSnapshot dead fields

The frontend stopped reading snapshot.progress, snapshot.currentResource,
and snapshot.currentStep when pdl-5 rewired the deploy panel + canvas
banner to derive every in-flight signal from `nodesById` (populated by
the typed node_status / node_progress wire). The server-side writers
have been writing dead state ever since.

Removed:
- progress: number, currentResource?, currentStep? from
  DeployProgressSnapshot interface (deploy-locks.ts).
- progress: 0 seed in startDeploySnapshot.
- progress: 100 stamp in finishDeploySnapshot.
- updateDeploySnapshot helper (no remaining callers after this drop).
- progress / currentResource writes in scheduler-callbacks (the totals
  count bump is preserved — callers still read it after the deploy
  returns).
- progress: 0 in canvas-deploy.ts /current/:cardId fallback.

The Json column on the Prisma `canvas_deployment.snapshot` field is
schemaless, so removing keys from the TS type doesn't migrate. Old DB
rows still carry these fields; new rows won't. The shared-modules
registry entry for the deploy-locks export set was trimmed to match.

Tests: 32 → 30 in scheduler-callbacks (caps-at-99 + write-only-
currentResource cases were specifically about the dead-field semantics
and dropped; the count-bump-on-terminal and per-node-mirror cases
preserved).

* ui: drop data.status legacy fallback per one-status-source-deploy-status

The compact-node deploy-pill render path read
`(data.deploy_status as string) || (data.status as string) || ''`,
making `data.status` a de-facto status source no matter how careful
the writer side stayed. Per the `one-status-source-deploy-status`
learning, removing the OR makes `deploy_status` the single source of
truth.

Coordinated with the writer-side sweep so no node-creation site seeds
`status: 'active'` anymore — the field stays empty until the deploy
pipeline writes `deploy_status`.

Sites cleaned (writer side):
- packages/blocks/src/{aws,azure,gcp}/security/waf.ts (3 WAF blueprints)
- packages/blocks/src/expand-blueprint.ts (mergedData seed)
- packages/templates/src/expand-template.ts (group + fallback resource)
- packages/ui/src/features/canvas/hooks/use-canvas-drop.ts (group + resource branches)
- packages/ui/src/store/slices/cards/reducers/undo-redo-group.ts
  (groupSelectedNodes new container)

Drift-checker writes (use-drift-check.ts) re-routed from `status:
'drifted' | 'active'` to `deploy_status:`-prefixed equivalents — drift
state IS a deploy outcome, not a separate field.

Reader side: compact-node/index.tsx:81 fallback dropped + comment
rewritten to point at the learning anchor for context.

Tests updated: use-canvas-drop (group + resource asserts), use-drift-
check (3 dispatched-payload asserts, both 'drifted' and 'active'),
undo-redo-group (drop the bounding-box test's status assertion).
8261 unit tests passing across the workspace; 0 typecheck regressions.

* rf-0c: hoist mapStatusToOverlay + overlayToWireStatus into @ice/types

The wire-status → canvas-overlay mapping had three redundant homes:
  - services/deploy/src/utils/deploy-event-formatter.ts:mapStatusToOverlay
  - packages/ui/src/features/deploy/hooks/use-deploy-subscription.ts
    :mapWireStatusToOverlay (+ overlayToWireStatus from 4c)

Both were kept in sync by hand and a long docstring warning
"Both sides must pick the same overlay string for the same wire status."
Drift between the two would mean a tab opened mid-deploy hydrates a
node with one color via the snapshot path and gets the same node
overwritten to a different color by the live event microseconds later.

Canonical home is now packages/types/src/deploy-events.ts next to
DeployNodeStatus. Service-side and UI-side modules re-export to
preserve consumer imports:
  - deploy-event-formatter.ts re-exports mapStatusToOverlay
  - use-deploy-subscription.ts re-exports
    `mapStatusToOverlay as mapWireStatusToOverlay` and overlayToWireStatus
    so existing UI callers (deploy-node-row, etc.) don't need a sweep

Also added DeployOverlayStatus type for callers that want the narrowed
return type of mapStatusToOverlay.

194 tests passing across @ice/types + @ice/ui + service-deploy; 0
typecheck regressions.

* rf-0c: unify network-container set via @ice/constants

Both @ice/types/connection-rules/predicates.ts:isContainer and
@ice/core/validation/classifiers.ts:isContainer hardcoded the same
list of network container iceTypes:
  - 'Network.VPC'
  - 'Network.Subnet'
  - 'Network.PrivateNetwork'

@ice/core/graph/classifier/category-classifier.ts:is_container_type
already read the canonical list from
@ice/constants:NETWORK_CONTAINER_TYPES — flipping the two `isContainer`
predicates to use the same constant means a new container type
(e.g. a future Network.PrivateLink) shows up everywhere in lockstep
without three coordinated edits.

The Group.* and nodeType-=='container'/'group' branches stay specific
to the connection-rules / validation `isContainer` predicates — they
serve a different purpose than the type-only `is_container_type`
function (which answers "is this a network container resource?").

The wider classifier dedup (the predicate set duplicated between
@ice/core/validation/classifiers.ts and @ice/types/connection-rules/
predicates.ts) is deferred — @ice/core deliberately does NOT depend
on @ice/types, and crossing that boundary would be a larger
architectural change than rf-0c warrants. The classifiers.ts
docstring was strengthened to flag the drift-watch responsibility.

194 tests passing; 0 typecheck regressions.

* progress: mark 2026-05-02 batch (Step 1-4) complete

Move the six deferred follow-ups, the Q2 learnings.md compaction, the
merge-story decision, and the progress-housekeeping pass from In flight
to Done this week. In flight is now empty.

* core: add 'auth' high-level resource (Cognito / Firebase Auth / Entra ID)

Adds a new managed-auth resource to the security category. Maps to:
  - aws  → aws:cognito:UserPool
  - gcp  → gcp:identitytoolkit:Tenant (Identity Platform / Firebase Auth)
  - azure → azure:aad:Tenant (Entra ID External Identities)

Properties: name, sign-in methods (list), MFA policy (off/optional/
required), password min length, session TTL hours.

Pairs with the Auth concept block landing in the next commit.
Test in high-level-resources-categories.test.ts updated to include the
new id in the security category's canonical-ids assertion.

* concepts: build Auth + Data Warehouse + Search blocks

Auth, Data Warehouse, and Search were originally deferred from the
23-block Concepts palette cut on 2026-04-14 — Auth dropped because
most modern apps use Clerk/Auth0 SaaS or NextAuth library paths,
Analytics dropped as niche for early-stage users. Reversal: building
them as proper concept blocks (the SaaS-key + library paths still work
the same — drop a Secret Store with the API key and use the SaaS SDK).

Three new blocks following the established pattern (blueprint.ts +
info.ts + index.ts; family/info registration on import):

- **auth** — iceType Security.Identity, family edge, category security.
  Compiles to Cognito User Pool / Firebase Auth tenant / Entra ID
  External Identities. Info covers when to use vs. SaaS-key vs.
  library paths.
- **data-warehouse** — iceType Analytics.DataWarehouse, family data,
  category data. Compiles to Redshift / BigQuery / Synapse. Info
  covers OLTP-vs-OLAP decision tree and per-provider pricing models.
- **search-engine** — iceType Analytics.Search, family data, category
  data. Compiles to OpenSearch / Vertex AI Search / Azure Cognitive
  Search. Info covers full-text vs. vector vs. tsvector tradeoffs and
  the Postgres → Worker → Search async-indexing pattern.

Concepts palette grows 25 → 28 blocks. Order in CONCEPT_BLUEPRINTS:
Data Warehouse + Search land in a new "Analytics" group between Data
and Messaging; Auth lands in Ops/Security between Observability and
Secret Store.

8261 tests passing across the workspace; @ice/blocks + @ice/ui
typecheck clean; @ice/core retains its documented 29-error TS2834
baseline (pre-existing, unrelated barrel files).

* progress: log 2026-05-02 Concepts palette shipment

Add a Done-this…
julia-kafarska added a commit that referenced this pull request May 25, 2026
* fix(canvas): correct snap target + drag-snap stickiness for Custom Domain rows

Two bugs in connection drag:
1. dragCompatibility positions used getPortAnchorPoint (schema's
   side-distribution math) instead of getSocketCanvasPosition, so the
   snap target Y on Custom Domain row ports drifted progressively
   from the visible dot (~50px on row 0, ~0px on the last row).
2. snap was computed from currentPoint, which itself is set to the
   snapped port's position. Distance-to-self stayed at 0 so the snap
   locked onto the first port that ever won. Added cursorPoint to
   DrawingConnectionState and compute snap from it; currentPoint
   remains the visible (snapped or cursor) wire endpoint.

* refactor(secret-store): schema-driven 1->N deploy expansion

Properties + deploy model rebuilt around the canonical schema:

- Properties: name -> "Store name"; secrets list -> new `secret_bindings`
  field with two inputs per row (env-var key ← upstream ref); auto_rotate
  dropped (was inert + wrong-scoped). Bindings explained: the block does
  NOT hold secret values — values live in the cloud secret manager.
- Schema: add optional `iceType` and `deployExpansion` to
  HighLevelResource. Secret Store declares
  `deployExpansion: { partitionBy: 'bindings', nameFrom: { field: 'ref',
  fallback: 'key' }, labelFrom: 'key', tagPerEntry: ... }`.
- Lookup: getHighLevelResourceByIceType helper with a cached index.
- Generic pass: new deploy/passes/deploy-expansion.ts emits one cloud
  resource per partition entry, dedup'd within/across blocks, forwarding
  provider-shaped properties verbatim. Knows nothing about secrets.
- Translator: the previous `if (iceType === 'Security.Secret')` branch
  is replaced with `if (schemaResource?.deployExpansion)` — cardinal
  rule, no iceType hardcoded in cross-cutting code.

Adding AWS Secrets Manager or Azure Key Vault requires only an extractor
+ handler for the provider's resource type. Adding a new expanding block
requires only a `deployExpansion` declaration on its schema entry.

* refactor(canvas-renderer): drop hardcoded iceType branches via SPECIAL_NODE_RENDERERS table

Audit item #11 — cardinal rule violation. The dispatcher had three
hardcoded `if (iceType === 'X')` branches for Custom Domain, Reroute,
and Private Network, each wiring a bespoke component with its own
prop set + innerKey formula.

Consolidated into a single declarative `SPECIAL_NODE_RENDERERS` table
keyed by iceType, with factory entries that own their own component
AND innerKey formula. The dispatcher iterates this table generically
— no iceType-specific code paths remain. Adding a new bespoke renderer
extends the table; the dispatcher stays unchanged.

Dispatch order preserved by construction: the special table is
consulted BEFORE the container check so PrivateNetwork (a container
we render with a custom header) and Reroute (which the classifier
calls a container despite being a pass-through dot) hit their
bespoke factories first.

Tests: locked-in entries list + per-entry contract (element + innerKey)
+ innerKey-changes-on-relevant-data assertions for Custom Domain
(routes count) and PrivateNetwork (ingress mode).

* refactor(canvas-path): drop hardcoded iceType in socket-position via BESPOKE_SOCKET_POSITIONS table

Audit item #12 — cardinal rule violation. `getSocketCanvasPosition`
had an `if (iceType === 'Network.CustomDomain' && socketId.startsWith
('domain-out-'))` branch that resolved the bespoke row-Y for per-route
ports.

Consolidated into a `BESPOKE_SOCKET_POSITIONS` table keyed by iceType,
with resolver entries returning `Point | null` (null = fall through to
the standard layout). The dispatcher iterates this table generically
— no iceType branches in the resolver function. New bespoke layouts
register here; dispatch stays unchanged.

Tests: locked-in entries list, per-resolver contract (returns null on
miss), and dispatcher behaviour (bespoke hit, fall-through, dangling
socket id).

* refactor(properties): schema-drive tab visibility + deployment-target skip

Audit item #13 — cardinal rule violation. The tab builder and the
deployment-target card both branched on hardcoded iceType strings:

  - build-visible-tabs.ts:41-46 — config tab visible for 4 iceTypes
  - build-visible-tabs.ts:53    — domain tab visible for 2 iceTypes
  - build-visible-tabs.ts:56    — source tab visible for Source.Repository
  - node-properties-section.tsx:166 — deployment target hidden for 2 iceTypes

Consolidated into a single declarative table
`BLOCK_PROPERTY_PANEL_CONFIGS` keyed by iceType, with per-block
`forceTabs` and `skipDeploymentTarget` flags. Both the builder and the
panel iterate this table generically — no iceType branches remain.
Adding a bespoke panel experience adds an entry; both call sites pick
it up.

Per-tab SECTION rendering (CustomDomainPanel, EnvVarsEditor, etc.)
inside the panel body is still iceType-conditioned — covered by audit
item #14 in the next commit, which extends this same config table.

* refactor(properties): schema-drive per-tab section dispatch via SECTION_COMPONENTS

Audit item #14 — cardinal rule violation. The panel body had six
hardcoded `iceType === 'X'` branches choosing which bespoke section
component to render inside which tab:

  - domain tab: PublicEndpointDomainSection / CustomDomainPanel
  - config tab: EnvVarsEditor / CustomDomainPanel / PrivateNetworkPanel
                / MonitoringLogSection
  - source tab: SourceRepositorySection
  - config tab fallback: SourceRepositorySection when no source tab

Extended BLOCK_PROPERTY_PANEL_CONFIGS with a `sections: Record<TabId,
SectionId[]>` field. Each iceType declares which sections render under
which tabs; the new SECTION_COMPONENTS factory map renders them.
`renderSectionsForTab(iceType, tab, ctx)` is the generic dispatcher
the JSX calls — no iceType branches remain.

Dropped the dead `visibleTabs.length <= 1 && iceType === 'Source.
Repository'` config-tab fallback: with the source tab now always forced
for that block via forceTabs, the fallback could never fire.

Tests: per-iceType set + section-id-tab validity check.

* refactor(canvas-sizing): schema-drive bespoke node sizing via BESPOKE_NODE_SIZING table

Audit item #16 — cardinal rule violation. computeNodeSizes had three
hardcoded iceType checks driving the width/height/fold dispatch:

  - isCustomDomain = iceType === 'Network.CustomDomain'
  - isPrivateNetwork = isPrivateNetworkIce(iceType)
  - isCronJob = iceType === 'Compute.CronJob'

Plus 4 nested ternaries threading those flags through width, height,
expandedHeight, and visualHeight.

Consolidated into BESPOKE_NODE_SIZING — a Record<iceType,
BespokeSizingEntry> where each entry owns its width function, height
function, and an `alwaysExpanded` flag that opts the block out of
folding (so dynamic content like Custom Domain route slots can't
collapse to a pill).

The dispatcher does a single table lookup, falls through to the
compact-node helpers when no bespoke entry exists, and respects
`alwaysExpanded` uniformly. No iceType branches remain.

Tests: locked-in entries list + alwaysExpanded invariant.

* refactor(deploy/edge-classifier): schema-drive isolation + standalone classification

Audit items #5 + #6 — cardinal rule violations. Three hardcoded
iceType checks in cross-cutting classifier code:

  - edge-classifier.ts:65  — `parent.data?.iceType === 'Network.PrivateNetwork'`
                             in the ancestor walk
  - edge-classifier.ts:90  — guard: `iceType !== 'Network.CustomDomain'`
  - edge-classifier.ts:93  — `parent.iceType !== 'Network.PrivateNetwork'`
                             in the standalone-mode check

Introduced `BLOCK_DEPLOY_CLASSIFIERS` — a per-iceType flag table with
two flags:

  - `isolatesNetworkContext`: this iceType is a network-isolation
    container (services nested inside should be internal-only)
  - `metadataOnlyWhenStandalone`: this iceType has two deploy modes
    based on parent context (metadata-only standalone vs. deployable
    when nested in an isolation container)

Renamed predicates to match the generic shape:
  - `hasPrivateNetworkAncestor` -> `hasNetworkIsolatingAncestor`
  - `isCustomDomainStandalone`  -> `isStandaloneMetadataOnly`

Old names kept as `@deprecated` aliases so external callers and tests
don't break. Card-translator call sites switched to the new names.
Adding a new isolation container or a new standalone/nested block
adds a table entry; classifier code stays unchanged.

* refactor(deploy/passes): schema-drive public-ingress detection + domain propagation

Audit items #7 + #8 — cardinal rule violations in two passes:

  - pass-1-5-endpoint-wiring.ts:107-114 — inline `isEndpointIceType`
    branched on Network.PublicEndpoint AND Network.CustomDomain (with
    nested-in-PrivateNetwork check) to identify ingress endpoints
  - pass-1-45-domain-propagation.ts:55-58 — hardcoded srcIce / dstIce
    === 'Network.CustomDomain' to route domain propagation

Extended BLOCK_DEPLOY_CLASSIFIERS with two new flags:

  - `publicIngressMode`: 'always' (PublicEndpoint) or
    'when-nested-in-isolated-network' (CustomDomain — only counts as
    ingress when nested inside an isolatesNetworkContext container)
  - `isDomainPropagator`: true for CustomDomain — generic name so any
    future domain-source block can flow through the same pass

Added generic `isPublicIngressNode(node, allNodes)` predicate to
edge-classifier that reads the flag table. Refactored pass-1-5 to
call it; pass-1-45 reads `isDomainPropagator` directly. No iceType
strings remain in either pass.

Tests: 3 new flag assertions + 5 new isPublicIngressNode behaviour
cases (always-mode, standalone CD, nested-in-isolated-network, nested-
in-non-isolation, plain compute).

* refactor(deploy/security-rules): schema-drive iceType classifiers via SECURITY_ROLES table

Audit item #15 — cardinal rule violation. The pre-deploy security
scanner had 9+ tiny iceType-comparing classifier functions
(`isDatabase`, `isStorage`, `isGateway`, `isService`, `isAuth`,
`isSecret`, `isMonitoring`, `isVpc`, `isSubnet`, `isPrivateNetwork`,
`isVpcLike`), each inlining its own `iceType === 'X'` check.

Consolidated into a schema-shaped role table:

  - `SECURITY_ROLES_BY_ICE_TYPE`: per-iceType role list
  - `SECURITY_ROLES_BY_PREFIX`: category-prefix inheritance
    (Database./Compute./Monitoring.* automatically pick up their role
    without per-iceType table edits)
  - `hasSecurityRole(iceType, role)`: the single lookup function the
    classifier readers call

Classifier functions remain as thin one-line role readers; rule
evaluation code is unchanged. New blocks that need a security role
add an entry to the table.

Split the previous `isVpcLike` into two distinct roles:
`isolatesNestedChildren` (VPC + Subnet + PrivateNetwork — used by the
ancestor check) and `topLevelNetworkBoundary` (VPC + PrivateNetwork
only — used by Rule 6, where a Subnet at the canvas root doesn't
isolate anything). The original code conflated these into a single
overloaded helper.

* refactor(classifiers): unify connection-rules + propagation-rules iceType classifiers via shared @ice/constants table

Audit items #9 + #10 — cardinal rule violations across two packages.
Both `@ice/types/connection-rules/predicates.ts` and
`@ice/core/compute/propagation-rules.ts` had ~15 identical-ish
classifier functions (isBackend, isFrontend, isDatabase, isCache,
isStorage, isQueue, isSecrets, isCustomDomain, …), each duplicating
the same regex + prefix + exact-match bodies. The propagation-rules
copy carried a comment apologising for the duplication ("Minimal
copies of the classifiers from @ice/types/connection-rules. Kept
local to avoid cross-package moduleResolution conflicts.").

Introduced `@ice/constants/block-classifiers.ts` as the single source
of truth — a three-tier role table:

  - `BLOCK_ROLES_BY_ICE_TYPE`: exact iceType -> roles
  - `BLOCK_ROLES_BY_PREFIX`:   category prefix -> role (Compute.*,
                               Database.*, Storage.*, Messaging.*,
                               Monitoring.*, Log.*)
  - `BLOCK_ROLES_BY_REGEX`:    legacy provider-specific iceTypes
                               (PostgreSQL/Redis/Bucket/Worker/…)
                               authored under varied namespaces

`hasBlockRole(t, role)` queries all three tiers. Both packages import
it — `predicates.ts` and `propagation-rules.ts` predicate bodies are
now one-line lookups, no iceType strings remain in classifier code.

Adding a new role/iceType binding edits ONE table; both connection-
rules and propagation-rules pick it up automatically.

Tests: full equivalence preserved (4664 tests pass) + new
block-classifiers test suite covering exact / prefix / regex /
composite / negative cases + table integrity.

* refactor(deploy): dedup SERVICE_BACKEND_ICE_TYPES via shared serviceBackend role

Audit items #17 + #18 — duplicated static set in two cross-cutting
locations:

  - edge-classifier.ts:34-40 — exported SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS
  - pass-1-5-endpoint-wiring.ts:188-194 — local SERVICE_BACKEND_ICE_TYPES

Both held the same 5 iceTypes (Compute.Container/BackendAPI/SSRSite/
Worker/ServerlessFunction). Two copies, two sources of drift.

Added a new `serviceBackend` role to the shared classifier table in
@ice/constants. The 5 iceTypes register the role via
BLOCK_ROLES_BY_ICE_TYPE — Compute.StaticSite is intentionally
excluded (compiles to backendBucket via Firebase Hosting, not a NEG).

- edge-classifier exports SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS as a
  thin materialisation derived from the role table (kept for callers
  that want `.has(t)` membership).
- pass-1-5-endpoint-wiring.ts now uses `hasBlockRole(t, 'serviceBackend')`
  directly; its inline duplicate set is gone.

New iceTypes register the role in ONE table; both consumers pick it up.

* refactor(translator): drop hardcoded iceType + provider branches via type-map + override table

Audit items #1 + #2 — two critical violations in the otherwise-
provider-agnostic translator:

  - card-translator.ts:227 — `ice_type === 'Network.CustomDomain'
    ? 'gcp.compute.globalForwardingRule' : type_map[ice_type]`
    (iceType AND GCP-specific in one cross-cutting line)
  - card-translator.ts:313-322 — `if (gcp_type === 'gcp.run.service')
    ... else if (gcp_type === 'aws.ecs.service') ... else if
    (gcp_type === 'azure.containerapp.containerApp') ...` cascade
    applying provider-specific internal-mode mutations inline

#1: Added Network.CustomDomain to each provider's type-map (mirrors
PublicEndpoint — the nested CD acts as the network's gateway and
compiles to the same ingress chain on every provider; standalone CDs
are filtered earlier by isStandaloneMetadataOnly). Translator now does
a plain `type_map[ice_type]` lookup — no iceType branches.

#2: Introduced `internal-ingress-overrides.ts` — a per-provider
mutator table keyed by resolved resource type:

  - gcp.run.service                      -> ingress=internal-and-cloud-load-balancing, allow_unauthenticated=false
  - aws.ecs.service                      -> assign_public_ip=false, internal=true
  - azure.containerapp.containerApp      -> ingress_external=false

Translator calls `applyInternalIngressOverride(resource_type, props)`
generically. The `SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(t)` set
membership is replaced with `hasBlockRole(t, 'serviceBackend')` per
the earlier dedup commit. No provider strings or iceType strings
appear in the translator's body.

New providers add an override entry; the translator stays unchanged.

Tests: existing type-map entry counts bumped by 1 + new CustomDomain
mapping assertions per provider + new internal-ingress-overrides
suite (table contents + per-provider behaviour + no-op fallthrough).

* refactor(deploy): drop hardcoded Compute.StaticSite in storage extractor + endpoint pass

Audit items #3 + #4 — last two cardinal-rule violations:

  - pass-1-5-endpoint-wiring.ts:204 — `if (be.targetIceType === 'Compute.StaticSite')`
    skipped LB wiring for static-site backends (GCP-Firebase-Hosting-specific behaviour)
  - extractors/network.ts:25 — `iceType === 'Compute.StaticSite'` flipped a Storage.Bucket
    extractor's `public_access` + `website_hosting` when the bucket backs a static site

Two schema-shaped declarations replace them, each scoped to its
natural layer:

  #3 -> `self-serving-resources.ts`: a new `SELF_SERVING_PUBLIC_RESOURCES`
       set keyed by RESOLVED PROVIDER RESOURCE TYPE (gcp.firebase.hosting
       today; future: aws.amplify.app, azure.staticwebapps.staticSite).
       The endpoint-wiring pass reads `targetGraphNode.type` and calls
       `isSelfServingPublicResource(...)` — no iceType strings.

  #4 -> New `publicWebsiteSource` role on the shared
       `BLOCK_ROLES_BY_ICE_TYPE` table. Compute.StaticSite registers
       this role; the storage extractor reads it via
       `hasBlockRole(iceType, 'publicWebsiteSource')`. Adding a future
       static-site-style block (Compute.JamstackSite, …) adds one
       table entry — extractor stays unchanged.

Tests:
  - existing pass-1-5 fixtures updated to mark static-site graph nodes
    with `type: 'gcp.firebase.hosting'` (the resolved type the new
    check reads); behavioural assertions unchanged
  - new self-serving-resources test suite covering registered +
    negative cases

With this commit, every audit item in the schema-driven-refactor
punch list is shipped.

* refactor(deploy/aws): modularise AWSDeployer to mirror gcp/ shape

Phase 0 of the AWS deploy buildout. The previous 496-LOC monolithic
aws-deployer.ts is replaced with the same dispatch shape the GCP
deployer uses:

  providers/aws/
    aws-deployer.ts   — thin dispatcher + HANDLER_REGISTRY map
    types.ts          — AWSHandlerContext + AWSResourceHandler
    sdk-loader.ts     — load_aws_sdk + initialize_aws_clients + destroy
    index.ts          — barrel
    handlers/
      ec2.ts          — migrated from aws-deployer.ts (no behaviour change)
      s3.ts           — migrated (account-id suffix arrives in commit #8)
      lambda.ts       — migrated (S3-ref only; auto-build in commit #28)

The old `providers/aws-deployer.ts` becomes a re-export shim so the
existing import paths in `providers/index.ts` and the test suite keep
resolving without edits.

Cardinal-rule schema-driven: HANDLER_REGISTRY is the single declarative
fact for "which handler runs for which resource type". The dispatcher
iterates it generically; no `if (type === 'aws.X')` branches. Adding
the next ~17 AWS services is now register-an-entry + drop-a-file.

Behaviour preserved verbatim — all 64 existing AWS deployer tests pass
unchanged (one minor wording fix in the dispatcher to match the
original "Unsupported resource type for creation/update/deletion"
phrasing the test suite pins).

* feat(deploy/aws): extractors for compute (ecs.service, lambda.function, events.rule)

Phase 1 commit 1/5 — first AWS extractor module.

  extractors/aws/compute.ts (new):
    - extract_ecs_service_properties   ← Compute.Container, Compute.BackendAPI,
                                          Compute.SSRSite, Compute.Worker
    - extract_lambda_function_properties ← Compute.ServerlessFunction
    - extract_events_rule_properties    ← Compute.CronJob

  extractors/dispatch.ts:
    Register the 3 extractors under their resolved aws.* resource types.
    Adds the first AWS section to PROPERTY_EXTRACTORS.

Provider parity notes (extractor only — handlers come later):
  - ECS multi-port uses the shared parse_exposed_ports() so the canvas
    contract matches what Cloud Run sees today.
  - Lambda accepts the nested code.{s3Bucket,s3Key} shape AND falls
    through to the flat s3_bucket / s3_key fields for back-compat with
    the existing Lambda test harness. Auto-build from Source.Repository
    lands in commit #28.
  - EventBridge cron is the 6-field format (not unix 5-field). The
    named "daily"/"hourly"/"weekly"/"monthly" presets that GCP Cloud
    Scheduler accepts are normalised to the AWS expression so cards
    stay provider-portable.

Tests: 24 new assertions covering defaults, passthrough, exposed_ports
parsing, code-source shape variants, cron preset normalisation. The
dispatch table test gets a new shape — counts GCP entries (27)
separately from AWS (>=3, will grow with commits #3#6), accepts
aws.* keys in the {provider}.{service}.{kind} regex.

* feat(deploy/aws): extractors for database (rds, dynamodb, elasticache, docdb)

Phase 1 commit 2/5.

  extractors/aws/database.ts (new):
    - extract_rds_db_instance_properties      ← Database.PostgreSQL, MySQL
    - extract_dynamodb_table_properties       ← Database.DynamoDB
    - extract_elasticache_cluster_properties  ← Database.Redis
    - extract_docdb_cluster_properties        ← Database.MongoDB

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Provider parity:
  - RDS engine + version inferred from iceType + runtime string, same
    rule the GCP Cloud SQL extractor uses -> cards stay portable.
  - master_user_password defaults to '' so the handler fails loudly
    rather than provisioning RDS / DocDB with no real credential.
  - ElastiCache exposes ELASTICACHE_REDIS_SIZE_MAP for the canvas
    M-series enum (M1 -> cache.t3.micro, M5 -> cache.m5.xlarge ×2 for HA
    parity with GCP STANDARD_HA).
  - DynamoDB defaults to PAY_PER_REQUEST (AWS-recommended for new
    workloads); PROVISIONED branch emits RCU/WCU only when set.

15 new test assertions across the four resources covering engine
detection, version extraction, size-enum translation, billing-mode
branching, and password defaults.

* feat(deploy/aws): extractors for network (s3, apigateway, cloudfront, elbv2)

Phase 1 commit 3/5.

  extractors/aws/network.ts (new):
    - extract_s3_bucket_properties             ← Storage.Bucket / Storage.ObjectStorage / Compute.StaticSite
    - extract_api_gateway_rest_api_properties  ← Network.Gateway
    - extract_cloudfront_distribution_properties ← Network.PublicEndpoint / Network.CustomDomain
    - extract_elbv2_load_balancer_properties   ← Network.LoadBalancer

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Cross-provider parity:
  - S3 reads the publicWebsiteSource role from the shared block-
    classifier table; same flip-policy the GCP cloud_storage extractor
    uses for Compute.StaticSite. Plain Storage.Bucket stays private.
  - CloudFront defaults to HTTPS + auto-cert + PriceClass_100 (most-
    common cost-aware preset). Cert provisioning in us-east-1 is the
    handler's job in commit #19.
  - ELBv2 defaults to internet-facing ALB on HTTPS:443; flips to
    `internal` scheme when `internal: true` (parity with the
    INTERNAL_INGRESS_OVERRIDES table semantics).

11 new tests + dispatch table assertion updated (the previous
"aws.s3.bucket is intentionally absent" assertion is replaced with
a generic "aws.unknown.thing" check now that S3 has landed).

* feat(deploy/aws): extractors for ancillary (sqs, sns, cognito, secrets, cw-logs)

Phase 1 commit 4/5.

  extractors/aws/ancillary.ts (new):
    - extract_sqs_queue_properties                ← Messaging.Queue
    - extract_sns_topic_properties                ← Messaging.Topic / CloudPubSub
    - extract_cognito_user_pool_properties        ← Security.Identity
    - extract_secrets_manager_secret_properties   ← Security.Secret
    - extract_cloudwatch_log_group_properties     ← Monitoring.Log

  extractors/dispatch.ts: register all 5 under aws.* resource types.

Notable:
  - SQS content_based_deduplication is only emitted on FIFO queues
    (AWS rejects the field on standard SQS).
  - Secrets Manager extractor forwards data.secrets as `bindings` —
    same shape the schema-declared deploy-expansion pass already uses
    for GCP Secret Manager. Adding AWS doesn't require translator
    changes; the same expansion branch fires.
  - Cognito reads both signInProviders (canvas camelCase) and
    sign_in_providers (snake) so projects authored with either work.

14 new test assertions.

* feat(deploy/aws): extractors for AI/analytics (opensearch, bedrock, sagemaker, redshift)

Phase 1 commit 5/5 — last extractor module. Every aws.* resource type
in AWS_TYPE_MAP now has a registered property extractor.

  extractors/aws/ai.ts (new):
    - extract_opensearch_domain_properties    ← AI.VectorDB
    - extract_bedrock_endpoint_properties     ← AI.LLMGateway
    - extract_sagemaker_endpoint_properties   ← AI.ModelServing
    - extract_redshift_cluster_properties     ← Analytics.DataWarehouse

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Notable defaults:
  - OpenSearch starts cost-conscious: single t3.small.search node with
    encryption-at-rest + node-to-node encryption on. Production users
    flip dedicated_master_enabled + bump instance_count ≥ 3.
  - Bedrock defaults to on-demand Claude 3 Haiku (zero provisioned
    model units -> handler emits no resource). Provisioned throughput
    fires only when model_units > 0.
  - SageMaker defaults to a real-time ml.t2.medium endpoint.
  - Redshift defaults to a single-node dc2.large with the no-default-
    password invariant the RDS + DocDB extractors share.

12 new test assertions. With this commit Phase 1 is complete:
PROPERTY_EXTRACTORS table now has 27 GCP + 20 AWS entries.

* feat(deploy/aws): shared infra — STS account-id resolver + IAM ensure-role helper

Phase 2 commit 1 — the shared helpers later handlers depend on.

  providers/aws/account.ts (new):
    - create_account_id_resolver(region): memoised STS GetCallerIdentity
      caller. First call hits STS, subsequent calls return cached value.
      Concurrent first-calls coalesce into one STS request. Throws a
      clear "install @aws-sdk/client-sts" message when SDK is absent.

  providers/aws/iam-roles.ts (new):
    - ensureManagedRole(region, roleName, trustPolicyJson, managedPolicyArn):
      idempotent GetRole -> CreateRole-on-NoSuchEntity -> AttachRolePolicy
      pattern. Returns the role ARN. Tolerates already-attached
      policies (AlreadyExists swallowed; any other error fatal).
    - ensureEcsTaskExecutionRole(region): convenience wrapper for the
      standard Fargate execution role (consumed by the ECS handler in
      commit #23).

  providers/aws/types.ts:
    AWSHandlerContext gains `ensure_account_id: AccountIdResolver` —
    handlers `await ctx.ensure_account_id()` to get the cached id.

  providers/aws/aws-deployer.ts:
    initialize() wires the resolver into the context. A pre-init stub
    throws "called before initialize()" if a handler tries to use it
    out of band.

  providers/aws/index.ts: re-export the new helpers.

Tests: 9 new — memoisation, concurrent-call coalescing, missing-SDK
error path, missing-Account-field error path, ensureManagedRole
happy-path + create-on-miss + IAM-SDK-missing path.

* feat(deploy/aws): s3 handler — account-id suffix + publicWebsite bucket policy

Handler #8 in Phase 2.

Two upgrades over the Phase 0 baseline:

  1. **Account-id suffix.** S3 bucket names are globally unique
     across all AWS accounts. The handler awaits ctx.ensure_account_id()
     and appends `-{accountId}` to the translator's resource name
     before any SDK call. `ice-myapp-bucket` becomes
     `ice-myapp-bucket-111122223333`, eliminating the global-collision
     class. The provider_id ARN carries the post-suffix name so
     update + delete round-trip cleanly (bucket_name_from_arn parses
     it back out).

  2. **publicWebsite policy.** When the extractor sets `public_access`
     + `website_hosting` (today only Compute.StaticSite triggers this
     via the publicWebsiteSource role from the shared classifier
     table), the handler runs a 4-step create:
       CreateBucket
         -> PutPublicAccessBlock (loosen account-default block)
         -> PutBucketPolicy      (attach the public-read policy)
         -> PutBucketWebsite     (set index/404 pages)
     Plain Storage.Bucket skips all three follow-up commands.

Tests:
  - Existing test harness extended with a makeStsModule mock + a
    FAKE_ACCOUNT_ID constant; the makeFullRegistry now installs STS
    alongside the SDK clients.
  - All existing S3 ARN assertions updated to expect the suffixed form.
  - 3 new tests: account-id suffix lock-in, public-website 4-step
    sequence with policy + website config, plain-bucket negative path.

64 -> 67 AWS deployer tests passing.

* feat(deploy/aws): lambda handler — fail-fast role + code-source validation

Handler #9 in Phase 2.

Hardens the existing Lambda S3-ref handler with two pre-create
validations that turn cryptic AWS API errors into clear messages:

  1. **IAM role required.** The AWS SDK returns "Could not find
     resource ..." when CreateFunction is called with an empty Role
     ARN. The handler now refuses up front with:
     "Lambda function requires an IAM execution role ARN
     (properties.role). Wire one in or use the auto-role helper."

  2. **Code source required.** When neither s3_bucket + s3_key NOR a
     base64 zip_file is supplied, the handler refuses with:
     "Lambda function code source is missing. Provide
     properties.code.{s3Bucket,s3Key} or zip_file (auto-build from
     Source.Repository lands in a later commit)."

Both checks fire before any SDK call, so the failure surfaces in the
deployer's `error` field with full context instead of as an opaque
AWS error.

Tests updated so happy-path Lambda create tests now pass both role
and code source. 2 new tests pin the fail-fast paths.

* feat(deploy/aws): cloudwatch-logs handler + shared _result helpers

Handler #10 in Phase 2.

  providers/aws/handlers/cloudwatch-logs.ts (new):
    - aws.cloudwatch.logGroup handler — CreateLogGroup +
      PutRetentionPolicy (when retention_in_days set) on create.
      PutRetentionPolicy on update. DeleteLogGroup on delete.

  providers/aws/handlers/_result.ts (new):
    - ok / err / sdkMissing helpers shared across all AWS handlers.
      Stops the per-handler result/fail boilerplate copy-paste.

  providers/aws/sdk-loader.ts: load @aws-sdk/client-cloudwatch-logs
    under the 'cloudwatch-logs' client key.

  providers/aws/aws-deployer.ts: register cloudwatch_logs_handler
    in HANDLER_REGISTRY.

Tests: 3 new — create-with-retention, create-without-retention skips
PutRetentionPolicy, delete sequence. Test harness extended with
makeCloudWatchLogsModule + corresponding FakeImportRegistry entry.

* feat(deploy/aws): secrets-manager handler + shared test harness

Handler #11 in Phase 2.

  providers/aws/handlers/secrets-manager.ts (new):
    - aws.secretsmanager.secret handler. Mirrors the GCP Secret
      Manager contract: the schema-declared deploy-expansion pass
      emits one Secret per binding row; this handler just creates /
      updates / deletes ONE. Values are NOT written (operators
      populate via AWS console/CLI — same security tradeoff as GCP).
    - delete uses ForceDeleteWithoutRecovery=true (skips the 30-day
      recovery window — appropriate when ICE removes the binding).

  providers/aws/sdk-loader.ts: load @aws-sdk/client-secrets-manager
    under the 'secrets-manager' client key.

  providers/aws/aws-deployer.ts: register secrets_manager_handler.

  providers/__tests__/_aws-test-harness.ts (new):
    Extracts the Function-constructor stub + generic SDK-mock factory
    out of the original aws-deployer.test.ts so per-handler test
    files stay small. Strips the trailing 'Command' from command
    class names when building the __cmd label so assertions read the
    operation name (`CreateSecret`, not `CreateSecretCommand`).

  providers/__tests__/aws-secrets-manager.test.ts (new): 4 focused
    tests — create returns the SDK ARN, update + delete sequences,
    SDK-not-installed path.

* feat(deploy/aws): sqs handler — CreateQueue/SetQueueAttributes/DeleteQueue, FIFO .fifo suffix

Handler #12 in Phase 2. Standard + FIFO queues. FIFO queues get the
.fifo suffix appended to the name automatically (AWS enforces). 3
focused tests.

* feat(deploy/aws): sns handler — CreateTopic/SetTopicAttributes/DeleteTopic, FIFO .fifo suffix

* feat(deploy/aws): dynamodb handler — CreateTable + key schema + PITR

* feat(deploy/aws): elasticache handler — single-node + replication-group paths

* feat(deploy/aws): rds handler — no-default-password gate + provisioning poll

Handler #16 in Phase 2. CreateDBInstance + 20-min status-poll loop
that respects ctx.abort_signal and reports progress via on_step.
Refuses to create when master_user_password is empty (parity with
the extractor's no-default-password invariant).

* feat(deploy/aws): docdb handler — cluster + per-instance creation

* feat(deploy/aws): cognito handler — user pool with password policy + MFA

* feat(deploy/aws): cloudfront handler — us-east-1 ACM cert + minimal distribution

Handler #19. CloudFront requires ACM certs in us-east-1 regardless
of deploy region; the handler spins up a one-shot ACM client pinned
to us-east-1 for RequestCertificate, then attaches the ARN to the
distribution's ViewerCertificate. Falls back to CloudFrontDefaultCertificate
when ACM SDK is absent.

* feat(deploy/aws): elbv2 handler — LB + skeleton target group

* feat(deploy/aws): api-gateway handler — REST API + default-stage deployment

* feat(deploy/aws): events-rule handler (CronJob) — PutRule + PutTargets

* feat(deploy/aws): ecs handler — auto-cluster + task role + service create

Handler #23. Compute.Container 'just works' on AWS — the handler
idempotently bootstraps ecsTaskExecutionRole + ice-default-cluster
before RegisterTaskDefinition + CreateService. Mirrors the GCP
Cloud Run UX (no cluster to think about).

* feat(deploy/aws): opensearch handler — CreateDomain with cluster/EBS/encryption config

* feat(deploy/aws): bedrock handler — on-demand no-op + provisioned-throughput create

* feat(deploy/aws): sagemaker handler — EndpointConfig + Endpoint, requires model_name

* feat(deploy/aws): redshift handler — CreateCluster + no-default-password gate

* feat(deploy/aws): lambda auto-build from Source.Repository

Phase 3 (commit #28). When a Compute.ServerlessFunction block has a
connected Source.Repository AND no explicit S3 ref, the handler
auto-builds the zip and uploads it before CreateFunction:

  1. git clone --depth 1 --branch <branch> <repo>
  2. npm install --omit=dev (skipped if no package.json)
  3. zip -qr function.zip .
  4. PutObject to ice-bootstrap-{accountId}-{region}/lambda/{name}/{ts}.zip
     (HeadBucket -> CreateBucket if absent)
  5. Stamp s3_bucket + s3_key onto properties and continue.

Local-only — assumes git/npm/zip on the deploy host. AWS CodeBuild
integration deferred to a future commit. Existing manual S3-ref + zip
paths are unchanged; the auto-build branch only fires when
`properties.repository` is set AND no explicit code source exists.

* test(deploy/aws): unskip AWS Type Map block + end-to-end coverage

Phase 4 commit #29. With every aws.* resource type registered in
PROPERTY_EXTRACTORS (commits #2#6), the AWS Type Map test block can
finally turn on. Expanded the iceType matrix from 5 to 19 entries
covering every AWS-mapped block.

New end-to-end test wires Compute.StaticSite + Security.Secret (with
two bindings — exercising the schema-declared deploy-expansion pass)
+ Database.PostgreSQL into a single translator call, asserts the
resulting graph has 4 deployables resolving to s3.bucket /
secretsmanager.secret×2 / rds.dbInstance. The Azure block remains
skipped (deferred to a future Azure handler buildout).

* docs(deploy/aws): provider notes — quirks, assumptions, deferred work

Phase 4 commit #30 — final commit of the AWS buildout.

providers/aws/README.md documents the AWS-specific decisions the 30
commits in this series bake in:

  - architecture (mirrors gcp/, schema-driven HANDLER_REGISTRY)
  - S3 account-id suffix
  - CloudFront us-east-1 cert
  - ECS auto-cluster + task role
  - RDS / DocDB / Redshift no-default-password invariant
  - RDS provisioning poll
  - Lambda auto-build flow (git + npm + zip + bootstrap S3)
  - Bedrock on-demand no-op
  - Secrets Manager values-never-written contract
  - SQS / SNS .fifo suffix
  - SDK packages as optional peer deps
  - test harness layout
  - deferred work (VPC blocks, CodeBuild, drift detection, LocalStack)

Read this before changing any AWS handler.

* feat(aws): selectively enable safe categories via feature flags

Flip PROVIDER_FLAGS.aws.enabled to true with a hand-picked category
map (Storage, Messaging, Cache, Monitoring, Security, Source, Config).
Compute / Frontend / Scheduler / Network / Database / AI / Analytics
stay gated until their concrete unblockers land — ECS VPC blocks,
CloudFront cert-validation flow, update-paths, etc.

README.md gets a Rollout state table documenting why each gated
category is held back and what unblocks it.

Integrity test in packages/constants asserts the per-category map
stays exhaustive, so future CategoryId additions force a deliberate
on/off decision here.

* fix(palette): enable provider dropdown items when any block is available

Replace the project-provider lock on the palette provider dropdown
with an availability check: a provider option is selectable iff at
least one concept has it in providers and its category is enabled
for that provider.

Before: in a GCP project, AWS was greyed in the palette dropdown
even after AWS feature-flag enabled — so users couldn't browse the
AWS catalog from a GCP project.

After: AWS opens as long as it has any available block under the
current PROVIDER_FLAGS — drag-into-project compatibility remains
enforced at the canvas-drop layer.

availableProviderIds is derived in resource-palette.tsx from the
unfiltered component list using isCategoryEnabledForProvider — same
schema-driven gate the component filter already uses.

* docs(architecture): explain how canvas edges become cloud infra

New page docs/architecture/connections-to-cloud.md walks the five-layer
pipeline (connection-rules -> propagation -> type-maps -> extractors ->
handlers) and grounds it with two worked GCP examples:

- Storage.Bucket -> Compute.BackendAPI: env-var injection + IAM binding,
  no edge resource in GCP.
- Compute.CronJob -> Compute.BackendAPI: Cloud Scheduler HTTP target +
  run.invoker IAM binding.

Links the new page from architecture/README.md and the existing
core-engine.md "Computing flows" section so readers landing on either
find their way to the deep dive.

* docs(architecture): explain how canvas edges become cloud infra
julia-kafarska added a commit that referenced this pull request May 25, 2026
* fix(canvas): correct snap target + drag-snap stickiness for Custom Domain rows

Two bugs in connection drag:
1. dragCompatibility positions used getPortAnchorPoint (schema's
   side-distribution math) instead of getSocketCanvasPosition, so the
   snap target Y on Custom Domain row ports drifted progressively
   from the visible dot (~50px on row 0, ~0px on the last row).
2. snap was computed from currentPoint, which itself is set to the
   snapped port's position. Distance-to-self stayed at 0 so the snap
   locked onto the first port that ever won. Added cursorPoint to
   DrawingConnectionState and compute snap from it; currentPoint
   remains the visible (snapped or cursor) wire endpoint.

* refactor(secret-store): schema-driven 1→N deploy expansion

Properties + deploy model rebuilt around the canonical schema:

- Properties: name → "Store name"; secrets list → new `secret_bindings`
  field with two inputs per row (env-var key ← upstream ref); auto_rotate
  dropped (was inert + wrong-scoped). Bindings explained: the block does
  NOT hold secret values — values live in the cloud secret manager.
- Schema: add optional `iceType` and `deployExpansion` to
  HighLevelResource. Secret Store declares
  `deployExpansion: { partitionBy: 'bindings', nameFrom: { field: 'ref',
  fallback: 'key' }, labelFrom: 'key', tagPerEntry: ... }`.
- Lookup: getHighLevelResourceByIceType helper with a cached index.
- Generic pass: new deploy/passes/deploy-expansion.ts emits one cloud
  resource per partition entry, dedup'd within/across blocks, forwarding
  provider-shaped properties verbatim. Knows nothing about secrets.
- Translator: the previous `if (iceType === 'Security.Secret')` branch
  is replaced with `if (schemaResource?.deployExpansion)` — cardinal
  rule, no iceType hardcoded in cross-cutting code.

Adding AWS Secrets Manager or Azure Key Vault requires only an extractor
+ handler for the provider's resource type. Adding a new expanding block
requires only a `deployExpansion` declaration on its schema entry.

* refactor(canvas-renderer): drop hardcoded iceType branches via SPECIAL_NODE_RENDERERS table

Audit item #11 — cardinal rule violation. The dispatcher had three
hardcoded `if (iceType === 'X')` branches for Custom Domain, Reroute,
and Private Network, each wiring a bespoke component with its own
prop set + innerKey formula.

Consolidated into a single declarative `SPECIAL_NODE_RENDERERS` table
keyed by iceType, with factory entries that own their own component
AND innerKey formula. The dispatcher iterates this table generically
— no iceType-specific code paths remain. Adding a new bespoke renderer
extends the table; the dispatcher stays unchanged.

Dispatch order preserved by construction: the special table is
consulted BEFORE the container check so PrivateNetwork (a container
we render with a custom header) and Reroute (which the classifier
calls a container despite being a pass-through dot) hit their
bespoke factories first.

Tests: locked-in entries list + per-entry contract (element + innerKey)
+ innerKey-changes-on-relevant-data assertions for Custom Domain
(routes count) and PrivateNetwork (ingress mode).

* refactor(canvas-path): drop hardcoded iceType in socket-position via BESPOKE_SOCKET_POSITIONS table

Audit item #12 — cardinal rule violation. `getSocketCanvasPosition`
had an `if (iceType === 'Network.CustomDomain' && socketId.startsWith
('domain-out-'))` branch that resolved the bespoke row-Y for per-route
ports.

Consolidated into a `BESPOKE_SOCKET_POSITIONS` table keyed by iceType,
with resolver entries returning `Point | null` (null = fall through to
the standard layout). The dispatcher iterates this table generically
— no iceType branches in the resolver function. New bespoke layouts
register here; dispatch stays unchanged.

Tests: locked-in entries list, per-resolver contract (returns null on
miss), and dispatcher behaviour (bespoke hit, fall-through, dangling
socket id).

* refactor(properties): schema-drive tab visibility + deployment-target skip

Audit item #13 — cardinal rule violation. The tab builder and the
deployment-target card both branched on hardcoded iceType strings:

  - build-visible-tabs.ts:41-46 — config tab visible for 4 iceTypes
  - build-visible-tabs.ts:53    — domain tab visible for 2 iceTypes
  - build-visible-tabs.ts:56    — source tab visible for Source.Repository
  - node-properties-section.tsx:166 — deployment target hidden for 2 iceTypes

Consolidated into a single declarative table
`BLOCK_PROPERTY_PANEL_CONFIGS` keyed by iceType, with per-block
`forceTabs` and `skipDeploymentTarget` flags. Both the builder and the
panel iterate this table generically — no iceType branches remain.
Adding a bespoke panel experience adds an entry; both call sites pick
it up.

Per-tab SECTION rendering (CustomDomainPanel, EnvVarsEditor, etc.)
inside the panel body is still iceType-conditioned — covered by audit
item #14 in the next commit, which extends this same config table.

* refactor(properties): schema-drive per-tab section dispatch via SECTION_COMPONENTS

Audit item #14 — cardinal rule violation. The panel body had six
hardcoded `iceType === 'X'` branches choosing which bespoke section
component to render inside which tab:

  - domain tab: PublicEndpointDomainSection / CustomDomainPanel
  - config tab: EnvVarsEditor / CustomDomainPanel / PrivateNetworkPanel
                / MonitoringLogSection
  - source tab: SourceRepositorySection
  - config tab fallback: SourceRepositorySection when no source tab

Extended BLOCK_PROPERTY_PANEL_CONFIGS with a `sections: Record<TabId,
SectionId[]>` field. Each iceType declares which sections render under
which tabs; the new SECTION_COMPONENTS factory map renders them.
`renderSectionsForTab(iceType, tab, ctx)` is the generic dispatcher
the JSX calls — no iceType branches remain.

Dropped the dead `visibleTabs.length <= 1 && iceType === 'Source.
Repository'` config-tab fallback: with the source tab now always forced
for that block via forceTabs, the fallback could never fire.

Tests: per-iceType set + section-id-tab validity check.

* refactor(canvas-sizing): schema-drive bespoke node sizing via BESPOKE_NODE_SIZING table

Audit item #16 — cardinal rule violation. computeNodeSizes had three
hardcoded iceType checks driving the width/height/fold dispatch:

  - isCustomDomain = iceType === 'Network.CustomDomain'
  - isPrivateNetwork = isPrivateNetworkIce(iceType)
  - isCronJob = iceType === 'Compute.CronJob'

Plus 4 nested ternaries threading those flags through width, height,
expandedHeight, and visualHeight.

Consolidated into BESPOKE_NODE_SIZING — a Record<iceType,
BespokeSizingEntry> where each entry owns its width function, height
function, and an `alwaysExpanded` flag that opts the block out of
folding (so dynamic content like Custom Domain route slots can't
collapse to a pill).

The dispatcher does a single table lookup, falls through to the
compact-node helpers when no bespoke entry exists, and respects
`alwaysExpanded` uniformly. No iceType branches remain.

Tests: locked-in entries list + alwaysExpanded invariant.

* refactor(deploy/edge-classifier): schema-drive isolation + standalone classification

Audit items #5 + #6 — cardinal rule violations. Three hardcoded
iceType checks in cross-cutting classifier code:

  - edge-classifier.ts:65  — `parent.data?.iceType === 'Network.PrivateNetwork'`
                             in the ancestor walk
  - edge-classifier.ts:90  — guard: `iceType !== 'Network.CustomDomain'`
  - edge-classifier.ts:93  — `parent.iceType !== 'Network.PrivateNetwork'`
                             in the standalone-mode check

Introduced `BLOCK_DEPLOY_CLASSIFIERS` — a per-iceType flag table with
two flags:

  - `isolatesNetworkContext`: this iceType is a network-isolation
    container (services nested inside should be internal-only)
  - `metadataOnlyWhenStandalone`: this iceType has two deploy modes
    based on parent context (metadata-only standalone vs. deployable
    when nested in an isolation container)

Renamed predicates to match the generic shape:
  - `hasPrivateNetworkAncestor` → `hasNetworkIsolatingAncestor`
  - `isCustomDomainStandalone`  → `isStandaloneMetadataOnly`

Old names kept as `@deprecated` aliases so external callers and tests
don't break. Card-translator call sites switched to the new names.
Adding a new isolation container or a new standalone/nested block
adds a table entry; classifier code stays unchanged.

* refactor(deploy/passes): schema-drive public-ingress detection + domain propagation

Audit items #7 + #8 — cardinal rule violations in two passes:

  - pass-1-5-endpoint-wiring.ts:107-114 — inline `isEndpointIceType`
    branched on Network.PublicEndpoint AND Network.CustomDomain (with
    nested-in-PrivateNetwork check) to identify ingress endpoints
  - pass-1-45-domain-propagation.ts:55-58 — hardcoded srcIce / dstIce
    === 'Network.CustomDomain' to route domain propagation

Extended BLOCK_DEPLOY_CLASSIFIERS with two new flags:

  - `publicIngressMode`: 'always' (PublicEndpoint) or
    'when-nested-in-isolated-network' (CustomDomain — only counts as
    ingress when nested inside an isolatesNetworkContext container)
  - `isDomainPropagator`: true for CustomDomain — generic name so any
    future domain-source block can flow through the same pass

Added generic `isPublicIngressNode(node, allNodes)` predicate to
edge-classifier that reads the flag table. Refactored pass-1-5 to
call it; pass-1-45 reads `isDomainPropagator` directly. No iceType
strings remain in either pass.

Tests: 3 new flag assertions + 5 new isPublicIngressNode behaviour
cases (always-mode, standalone CD, nested-in-isolated-network, nested-
in-non-isolation, plain compute).

* refactor(deploy/security-rules): schema-drive iceType classifiers via SECURITY_ROLES table

Audit item #15 — cardinal rule violation. The pre-deploy security
scanner had 9+ tiny iceType-comparing classifier functions
(`isDatabase`, `isStorage`, `isGateway`, `isService`, `isAuth`,
`isSecret`, `isMonitoring`, `isVpc`, `isSubnet`, `isPrivateNetwork`,
`isVpcLike`), each inlining its own `iceType === 'X'` check.

Consolidated into a schema-shaped role table:

  - `SECURITY_ROLES_BY_ICE_TYPE`: per-iceType role list
  - `SECURITY_ROLES_BY_PREFIX`: category-prefix inheritance
    (Database./Compute./Monitoring.* automatically pick up their role
    without per-iceType table edits)
  - `hasSecurityRole(iceType, role)`: the single lookup function the
    classifier readers call

Classifier functions remain as thin one-line role readers; rule
evaluation code is unchanged. New blocks that need a security role
add an entry to the table.

Split the previous `isVpcLike` into two distinct roles:
`isolatesNestedChildren` (VPC + Subnet + PrivateNetwork — used by the
ancestor check) and `topLevelNetworkBoundary` (VPC + PrivateNetwork
only — used by Rule 6, where a Subnet at the canvas root doesn't
isolate anything). The original code conflated these into a single
overloaded helper.

* refactor(classifiers): unify connection-rules + propagation-rules iceType classifiers via shared @ice/constants table

Audit items #9 + #10 — cardinal rule violations across two packages.
Both `@ice/types/connection-rules/predicates.ts` and
`@ice/core/compute/propagation-rules.ts` had ~15 identical-ish
classifier functions (isBackend, isFrontend, isDatabase, isCache,
isStorage, isQueue, isSecrets, isCustomDomain, …), each duplicating
the same regex + prefix + exact-match bodies. The propagation-rules
copy carried a comment apologising for the duplication ("Minimal
copies of the classifiers from @ice/types/connection-rules. Kept
local to avoid cross-package moduleResolution conflicts.").

Introduced `@ice/constants/block-classifiers.ts` as the single source
of truth — a three-tier role table:

  - `BLOCK_ROLES_BY_ICE_TYPE`: exact iceType → roles
  - `BLOCK_ROLES_BY_PREFIX`:   category prefix → role (Compute.*,
                               Database.*, Storage.*, Messaging.*,
                               Monitoring.*, Log.*)
  - `BLOCK_ROLES_BY_REGEX`:    legacy provider-specific iceTypes
                               (PostgreSQL/Redis/Bucket/Worker/…)
                               authored under varied namespaces

`hasBlockRole(t, role)` queries all three tiers. Both packages import
it — `predicates.ts` and `propagation-rules.ts` predicate bodies are
now one-line lookups, no iceType strings remain in classifier code.

Adding a new role/iceType binding edits ONE table; both connection-
rules and propagation-rules pick it up automatically.

Tests: full equivalence preserved (4664 tests pass) + new
block-classifiers test suite covering exact / prefix / regex /
composite / negative cases + table integrity.

* refactor(deploy): dedup SERVICE_BACKEND_ICE_TYPES via shared serviceBackend role

Audit items #17 + #18 — duplicated static set in two cross-cutting
locations:

  - edge-classifier.ts:34-40 — exported SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS
  - pass-1-5-endpoint-wiring.ts:188-194 — local SERVICE_BACKEND_ICE_TYPES

Both held the same 5 iceTypes (Compute.Container/BackendAPI/SSRSite/
Worker/ServerlessFunction). Two copies, two sources of drift.

Added a new `serviceBackend` role to the shared classifier table in
@ice/constants. The 5 iceTypes register the role via
BLOCK_ROLES_BY_ICE_TYPE — Compute.StaticSite is intentionally
excluded (compiles to backendBucket via Firebase Hosting, not a NEG).

- edge-classifier exports SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS as a
  thin materialisation derived from the role table (kept for callers
  that want `.has(t)` membership).
- pass-1-5-endpoint-wiring.ts now uses `hasBlockRole(t, 'serviceBackend')`
  directly; its inline duplicate set is gone.

New iceTypes register the role in ONE table; both consumers pick it up.

* refactor(translator): drop hardcoded iceType + provider branches via type-map + override table

Audit items #1 + #2 — two critical violations in the otherwise-
provider-agnostic translator:

  - card-translator.ts:227 — `ice_type === 'Network.CustomDomain'
    ? 'gcp.compute.globalForwardingRule' : type_map[ice_type]`
    (iceType AND GCP-specific in one cross-cutting line)
  - card-translator.ts:313-322 — `if (gcp_type === 'gcp.run.service')
    ... else if (gcp_type === 'aws.ecs.service') ... else if
    (gcp_type === 'azure.containerapp.containerApp') ...` cascade
    applying provider-specific internal-mode mutations inline

#1: Added Network.CustomDomain to each provider's type-map (mirrors
PublicEndpoint — the nested CD acts as the network's gateway and
compiles to the same ingress chain on every provider; standalone CDs
are filtered earlier by isStandaloneMetadataOnly). Translator now does
a plain `type_map[ice_type]` lookup — no iceType branches.

#2: Introduced `internal-ingress-overrides.ts` — a per-provider
mutator table keyed by resolved resource type:

  - gcp.run.service                      → ingress=internal-and-cloud-load-balancing, allow_unauthenticated=false
  - aws.ecs.service                      → assign_public_ip=false, internal=true
  - azure.containerapp.containerApp      → ingress_external=false

Translator calls `applyInternalIngressOverride(resource_type, props)`
generically. The `SERVICE_BACKEND_ICE_TYPES_FOR_INGRESS.has(t)` set
membership is replaced with `hasBlockRole(t, 'serviceBackend')` per
the earlier dedup commit. No provider strings or iceType strings
appear in the translator's body.

New providers add an override entry; the translator stays unchanged.

Tests: existing type-map entry counts bumped by 1 + new CustomDomain
mapping assertions per provider + new internal-ingress-overrides
suite (table contents + per-provider behaviour + no-op fallthrough).

* refactor(deploy): drop hardcoded Compute.StaticSite in storage extractor + endpoint pass

Audit items #3 + #4 — last two cardinal-rule violations:

  - pass-1-5-endpoint-wiring.ts:204 — `if (be.targetIceType === 'Compute.StaticSite')`
    skipped LB wiring for static-site backends (GCP-Firebase-Hosting-specific behaviour)
  - extractors/network.ts:25 — `iceType === 'Compute.StaticSite'` flipped a Storage.Bucket
    extractor's `public_access` + `website_hosting` when the bucket backs a static site

Two schema-shaped declarations replace them, each scoped to its
natural layer:

  #3 → `self-serving-resources.ts`: a new `SELF_SERVING_PUBLIC_RESOURCES`
       set keyed by RESOLVED PROVIDER RESOURCE TYPE (gcp.firebase.hosting
       today; future: aws.amplify.app, azure.staticwebapps.staticSite).
       The endpoint-wiring pass reads `targetGraphNode.type` and calls
       `isSelfServingPublicResource(...)` — no iceType strings.

  #4 → New `publicWebsiteSource` role on the shared
       `BLOCK_ROLES_BY_ICE_TYPE` table. Compute.StaticSite registers
       this role; the storage extractor reads it via
       `hasBlockRole(iceType, 'publicWebsiteSource')`. Adding a future
       static-site-style block (Compute.JamstackSite, …) adds one
       table entry — extractor stays unchanged.

Tests:
  - existing pass-1-5 fixtures updated to mark static-site graph nodes
    with `type: 'gcp.firebase.hosting'` (the resolved type the new
    check reads); behavioural assertions unchanged
  - new self-serving-resources test suite covering registered +
    negative cases

With this commit, every audit item in the schema-driven-refactor
punch list is shipped.

* refactor(deploy/aws): modularise AWSDeployer to mirror gcp/ shape

Phase 0 of the AWS deploy buildout. The previous 496-LOC monolithic
aws-deployer.ts is replaced with the same dispatch shape the GCP
deployer uses:

  providers/aws/
    aws-deployer.ts   — thin dispatcher + HANDLER_REGISTRY map
    types.ts          — AWSHandlerContext + AWSResourceHandler
    sdk-loader.ts     — load_aws_sdk + initialize_aws_clients + destroy
    index.ts          — barrel
    handlers/
      ec2.ts          — migrated from aws-deployer.ts (no behaviour change)
      s3.ts           — migrated (account-id suffix arrives in commit #8)
      lambda.ts       — migrated (S3-ref only; auto-build in commit #28)

The old `providers/aws-deployer.ts` becomes a re-export shim so the
existing import paths in `providers/index.ts` and the test suite keep
resolving without edits.

Cardinal-rule schema-driven: HANDLER_REGISTRY is the single declarative
fact for "which handler runs for which resource type". The dispatcher
iterates it generically; no `if (type === 'aws.X')` branches. Adding
the next ~17 AWS services is now register-an-entry + drop-a-file.

Behaviour preserved verbatim — all 64 existing AWS deployer tests pass
unchanged (one minor wording fix in the dispatcher to match the
original "Unsupported resource type for creation/update/deletion"
phrasing the test suite pins).

* feat(deploy/aws): extractors for compute (ecs.service, lambda.function, events.rule)

Phase 1 commit 1/5 — first AWS extractor module.

  extractors/aws/compute.ts (new):
    - extract_ecs_service_properties   ← Compute.Container, Compute.BackendAPI,
                                          Compute.SSRSite, Compute.Worker
    - extract_lambda_function_properties ← Compute.ServerlessFunction
    - extract_events_rule_properties    ← Compute.CronJob

  extractors/dispatch.ts:
    Register the 3 extractors under their resolved aws.* resource types.
    Adds the first AWS section to PROPERTY_EXTRACTORS.

Provider parity notes (extractor only — handlers come later):
  - ECS multi-port uses the shared parse_exposed_ports() so the canvas
    contract matches what Cloud Run sees today.
  - Lambda accepts the nested code.{s3Bucket,s3Key} shape AND falls
    through to the flat s3_bucket / s3_key fields for back-compat with
    the existing Lambda test harness. Auto-build from Source.Repository
    lands in commit #28.
  - EventBridge cron is the 6-field format (not unix 5-field). The
    named "daily"/"hourly"/"weekly"/"monthly" presets that GCP Cloud
    Scheduler accepts are normalised to the AWS expression so cards
    stay provider-portable.

Tests: 24 new assertions covering defaults, passthrough, exposed_ports
parsing, code-source shape variants, cron preset normalisation. The
dispatch table test gets a new shape — counts GCP entries (27)
separately from AWS (>=3, will grow with commits #3#6), accepts
aws.* keys in the {provider}.{service}.{kind} regex.

* feat(deploy/aws): extractors for database (rds, dynamodb, elasticache, docdb)

Phase 1 commit 2/5.

  extractors/aws/database.ts (new):
    - extract_rds_db_instance_properties      ← Database.PostgreSQL, MySQL
    - extract_dynamodb_table_properties       ← Database.DynamoDB
    - extract_elasticache_cluster_properties  ← Database.Redis
    - extract_docdb_cluster_properties        ← Database.MongoDB

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Provider parity:
  - RDS engine + version inferred from iceType + runtime string, same
    rule the GCP Cloud SQL extractor uses → cards stay portable.
  - master_user_password defaults to '' so the handler fails loudly
    rather than provisioning RDS / DocDB with no real credential.
  - ElastiCache exposes ELASTICACHE_REDIS_SIZE_MAP for the canvas
    M-series enum (M1 → cache.t3.micro, M5 → cache.m5.xlarge ×2 for HA
    parity with GCP STANDARD_HA).
  - DynamoDB defaults to PAY_PER_REQUEST (AWS-recommended for new
    workloads); PROVISIONED branch emits RCU/WCU only when set.

15 new test assertions across the four resources covering engine
detection, version extraction, size-enum translation, billing-mode
branching, and password defaults.

* feat(deploy/aws): extractors for network (s3, apigateway, cloudfront, elbv2)

Phase 1 commit 3/5.

  extractors/aws/network.ts (new):
    - extract_s3_bucket_properties             ← Storage.Bucket / Storage.ObjectStorage / Compute.StaticSite
    - extract_api_gateway_rest_api_properties  ← Network.Gateway
    - extract_cloudfront_distribution_properties ← Network.PublicEndpoint / Network.CustomDomain
    - extract_elbv2_load_balancer_properties   ← Network.LoadBalancer

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Cross-provider parity:
  - S3 reads the publicWebsiteSource role from the shared block-
    classifier table; same flip-policy the GCP cloud_storage extractor
    uses for Compute.StaticSite. Plain Storage.Bucket stays private.
  - CloudFront defaults to HTTPS + auto-cert + PriceClass_100 (most-
    common cost-aware preset). Cert provisioning in us-east-1 is the
    handler's job in commit #19.
  - ELBv2 defaults to internet-facing ALB on HTTPS:443; flips to
    `internal` scheme when `internal: true` (parity with the
    INTERNAL_INGRESS_OVERRIDES table semantics).

11 new tests + dispatch table assertion updated (the previous
"aws.s3.bucket is intentionally absent" assertion is replaced with
a generic "aws.unknown.thing" check now that S3 has landed).

* feat(deploy/aws): extractors for ancillary (sqs, sns, cognito, secrets, cw-logs)

Phase 1 commit 4/5.

  extractors/aws/ancillary.ts (new):
    - extract_sqs_queue_properties                ← Messaging.Queue
    - extract_sns_topic_properties                ← Messaging.Topic / CloudPubSub
    - extract_cognito_user_pool_properties        ← Security.Identity
    - extract_secrets_manager_secret_properties   ← Security.Secret
    - extract_cloudwatch_log_group_properties     ← Monitoring.Log

  extractors/dispatch.ts: register all 5 under aws.* resource types.

Notable:
  - SQS content_based_deduplication is only emitted on FIFO queues
    (AWS rejects the field on standard SQS).
  - Secrets Manager extractor forwards data.secrets as `bindings` —
    same shape the schema-declared deploy-expansion pass already uses
    for GCP Secret Manager. Adding AWS doesn't require translator
    changes; the same expansion branch fires.
  - Cognito reads both signInProviders (canvas camelCase) and
    sign_in_providers (snake) so projects authored with either work.

14 new test assertions.

* feat(deploy/aws): extractors for AI/analytics (opensearch, bedrock, sagemaker, redshift)

Phase 1 commit 5/5 — last extractor module. Every aws.* resource type
in AWS_TYPE_MAP now has a registered property extractor.

  extractors/aws/ai.ts (new):
    - extract_opensearch_domain_properties    ← AI.VectorDB
    - extract_bedrock_endpoint_properties     ← AI.LLMGateway
    - extract_sagemaker_endpoint_properties   ← AI.ModelServing
    - extract_redshift_cluster_properties     ← Analytics.DataWarehouse

  extractors/dispatch.ts: register all 4 under aws.* resource types.

Notable defaults:
  - OpenSearch starts cost-conscious: single t3.small.search node with
    encryption-at-rest + node-to-node encryption on. Production users
    flip dedicated_master_enabled + bump instance_count ≥ 3.
  - Bedrock defaults to on-demand Claude 3 Haiku (zero provisioned
    model units → handler emits no resource). Provisioned throughput
    fires only when model_units > 0.
  - SageMaker defaults to a real-time ml.t2.medium endpoint.
  - Redshift defaults to a single-node dc2.large with the no-default-
    password invariant the RDS + DocDB extractors share.

12 new test assertions. With this commit Phase 1 is complete:
PROPERTY_EXTRACTORS table now has 27 GCP + 20 AWS entries.

* feat(deploy/aws): shared infra — STS account-id resolver + IAM ensure-role helper

Phase 2 commit 1 — the shared helpers later handlers depend on.

  providers/aws/account.ts (new):
    - create_account_id_resolver(region): memoised STS GetCallerIdentity
      caller. First call hits STS, subsequent calls return cached value.
      Concurrent first-calls coalesce into one STS request. Throws a
      clear "install @aws-sdk/client-sts" message when SDK is absent.

  providers/aws/iam-roles.ts (new):
    - ensureManagedRole(region, roleName, trustPolicyJson, managedPolicyArn):
      idempotent GetRole → CreateRole-on-NoSuchEntity → AttachRolePolicy
      pattern. Returns the role ARN. Tolerates already-attached
      policies (AlreadyExists swallowed; any other error fatal).
    - ensureEcsTaskExecutionRole(region): convenience wrapper for the
      standard Fargate execution role (consumed by the ECS handler in
      commit #23).

  providers/aws/types.ts:
    AWSHandlerContext gains `ensure_account_id: AccountIdResolver` —
    handlers `await ctx.ensure_account_id()` to get the cached id.

  providers/aws/aws-deployer.ts:
    initialize() wires the resolver into the context. A pre-init stub
    throws "called before initialize()" if a handler tries to use it
    out of band.

  providers/aws/index.ts: re-export the new helpers.

Tests: 9 new — memoisation, concurrent-call coalescing, missing-SDK
error path, missing-Account-field error path, ensureManagedRole
happy-path + create-on-miss + IAM-SDK-missing path.

* feat(deploy/aws): s3 handler — account-id suffix + publicWebsite bucket policy

Handler #8 in Phase 2.

Two upgrades over the Phase 0 baseline:

  1. **Account-id suffix.** S3 bucket names are globally unique
     across all AWS accounts. The handler awaits ctx.ensure_account_id()
     and appends `-{accountId}` to the translator's resource name
     before any SDK call. `ice-myapp-bucket` becomes
     `ice-myapp-bucket-111122223333`, eliminating the global-collision
     class. The provider_id ARN carries the post-suffix name so
     update + delete round-trip cleanly (bucket_name_from_arn parses
     it back out).

  2. **publicWebsite policy.** When the extractor sets `public_access`
     + `website_hosting` (today only Compute.StaticSite triggers this
     via the publicWebsiteSource role from the shared classifier
     table), the handler runs a 4-step create:
       CreateBucket
         → PutPublicAccessBlock (loosen account-default block)
         → PutBucketPolicy      (attach the public-read policy)
         → PutBucketWebsite     (set index/404 pages)
     Plain Storage.Bucket skips all three follow-up commands.

Tests:
  - Existing test harness extended with a makeStsModule mock + a
    FAKE_ACCOUNT_ID constant; the makeFullRegistry now installs STS
    alongside the SDK clients.
  - All existing S3 ARN assertions updated to expect the suffixed form.
  - 3 new tests: account-id suffix lock-in, public-website 4-step
    sequence with policy + website config, plain-bucket negative path.

64 → 67 AWS deployer tests passing.

* feat(deploy/aws): lambda handler — fail-fast role + code-source validation

Handler #9 in Phase 2.

Hardens the existing Lambda S3-ref handler with two pre-create
validations that turn cryptic AWS API errors into clear messages:

  1. **IAM role required.** The AWS SDK returns "Could not find
     resource ..." when CreateFunction is called with an empty Role
     ARN. The handler now refuses up front with:
     "Lambda function requires an IAM execution role ARN
     (properties.role). Wire one in or use the auto-role helper."

  2. **Code source required.** When neither s3_bucket + s3_key NOR a
     base64 zip_file is supplied, the handler refuses with:
     "Lambda function code source is missing. Provide
     properties.code.{s3Bucket,s3Key} or zip_file (auto-build from
     Source.Repository lands in a later commit)."

Both checks fire before any SDK call, so the failure surfaces in the
deployer's `error` field with full context instead of as an opaque
AWS error.

Tests updated so happy-path Lambda create tests now pass both role
and code source. 2 new tests pin the fail-fast paths.

* feat(deploy/aws): cloudwatch-logs handler + shared _result helpers

Handler #10 in Phase 2.

  providers/aws/handlers/cloudwatch-logs.ts (new):
    - aws.cloudwatch.logGroup handler — CreateLogGroup +
      PutRetentionPolicy (when retention_in_days set) on create.
      PutRetentionPolicy on update. DeleteLogGroup on delete.

  providers/aws/handlers/_result.ts (new):
    - ok / err / sdkMissing helpers shared across all AWS handlers.
      Stops the per-handler result/fail boilerplate copy-paste.

  providers/aws/sdk-loader.ts: load @aws-sdk/client-cloudwatch-logs
    under the 'cloudwatch-logs' client key.

  providers/aws/aws-deployer.ts: register cloudwatch_logs_handler
    in HANDLER_REGISTRY.

Tests: 3 new — create-with-retention, create-without-retention skips
PutRetentionPolicy, delete sequence. Test harness extended with
makeCloudWatchLogsModule + corresponding FakeImportRegistry entry.

* feat(deploy/aws): secrets-manager handler + shared test harness

Handler #11 in Phase 2.

  providers/aws/handlers/secrets-manager.ts (new):
    - aws.secretsmanager.secret handler. Mirrors the GCP Secret
      Manager contract: the schema-declared deploy-expansion pass
      emits one Secret per binding row; this handler just creates /
      updates / deletes ONE. Values are NOT written (operators
      populate via AWS console/CLI — same security tradeoff as GCP).
    - delete uses ForceDeleteWithoutRecovery=true (skips the 30-day
      recovery window — appropriate when ICE removes the binding).

  providers/aws/sdk-loader.ts: load @aws-sdk/client-secrets-manager
    under the 'secrets-manager' client key.

  providers/aws/aws-deployer.ts: register secrets_manager_handler.

  providers/__tests__/_aws-test-harness.ts (new):
    Extracts the Function-constructor stub + generic SDK-mock factory
    out of the original aws-deployer.test.ts so per-handler test
    files stay small. Strips the trailing 'Command' from command
    class names when building the __cmd label so assertions read the
    operation name (`CreateSecret`, not `CreateSecretCommand`).

  providers/__tests__/aws-secrets-manager.test.ts (new): 4 focused
    tests — create returns the SDK ARN, update + delete sequences,
    SDK-not-installed path.

* feat(deploy/aws): sqs handler — CreateQueue/SetQueueAttributes/DeleteQueue, FIFO .fifo suffix

Handler #12 in Phase 2. Standard + FIFO queues. FIFO queues get the
.fifo suffix appended to the name automatically (AWS enforces). 3
focused tests.

* feat(deploy/aws): sns handler — CreateTopic/SetTopicAttributes/DeleteTopic, FIFO .fifo suffix

* feat(deploy/aws): dynamodb handler — CreateTable + key schema + PITR

* feat(deploy/aws): elasticache handler — single-node + replication-group paths

* feat(deploy/aws): rds handler — no-default-password gate + provisioning poll

Handler #16 in Phase 2. CreateDBInstance + 20-min status-poll loop
that respects ctx.abort_signal and reports progress via on_step.
Refuses to create when master_user_password is empty (parity with
the extractor's no-default-password invariant).

* feat(deploy/aws): docdb handler — cluster + per-instance creation

* feat(deploy/aws): cognito handler — user pool with password policy + MFA

* feat(deploy/aws): cloudfront handler — us-east-1 ACM cert + minimal distribution

Handler #19. CloudFront requires ACM certs in us-east-1 regardless
of deploy region; the handler spins up a one-shot ACM client pinned
to us-east-1 for RequestCertificate, then attaches the ARN to the
distribution's ViewerCertificate. Falls back to CloudFrontDefaultCertificate
when ACM SDK is absent.

* feat(deploy/aws): elbv2 handler — LB + skeleton target group

* feat(deploy/aws): api-gateway handler — REST API + default-stage deployment

* feat(deploy/aws): events-rule handler (CronJob) — PutRule + PutTargets

* feat(deploy/aws): ecs handler — auto-cluster + task role + service create

Handler #23. Compute.Container 'just works' on AWS — the handler
idempotently bootstraps ecsTaskExecutionRole + ice-default-cluster
before RegisterTaskDefinition + CreateService. Mirrors the GCP
Cloud Run UX (no cluster to think about).

* feat(deploy/aws): opensearch handler — CreateDomain with cluster/EBS/encryption config

* feat(deploy/aws): bedrock handler — on-demand no-op + provisioned-throughput create

* feat(deploy/aws): sagemaker handler — EndpointConfig + Endpoint, requires model_name

* feat(deploy/aws): redshift handler — CreateCluster + no-default-password gate

* feat(deploy/aws): lambda auto-build from Source.Repository

Phase 3 (commit #28). When a Compute.ServerlessFunction block has a
connected Source.Repository AND no explicit S3 ref, the handler
auto-builds the zip and uploads it before CreateFunction:

  1. git clone --depth 1 --branch <branch> <repo>
  2. npm install --omit=dev (skipped if no package.json)
  3. zip -qr function.zip .
  4. PutObject to ice-bootstrap-{accountId}-{region}/lambda/{name}/{ts}.zip
     (HeadBucket → CreateBucket if absent)
  5. Stamp s3_bucket + s3_key onto properties and continue.

Local-only — assumes git/npm/zip on the deploy host. AWS CodeBuild
integration deferred to a future commit. Existing manual S3-ref + zip
paths are unchanged; the auto-build branch only fires when
`properties.repository` is set AND no explicit code source exists.

* test(deploy/aws): unskip AWS Type Map block + end-to-end coverage

Phase 4 commit #29. With every aws.* resource type registered in
PROPERTY_EXTRACTORS (commits #2#6), the AWS Type Map test block can
finally turn on. Expanded the iceType matrix from 5 to 19 entries
covering every AWS-mapped block.

New end-to-end test wires Compute.StaticSite + Security.Secret (with
two bindings — exercising the schema-declared deploy-expansion pass)
+ Database.PostgreSQL into a single translator call, asserts the
resulting graph has 4 deployables resolving to s3.bucket /
secretsmanager.secret×2 / rds.dbInstance. The Azure block remains
skipped (deferred to a future Azure handler buildout).

* docs(deploy/aws): provider notes — quirks, assumptions, deferred work

Phase 4 commit #30 — final commit of the AWS buildout.

providers/aws/README.md documents the AWS-specific decisions the 30
commits in this series bake in:

  - architecture (mirrors gcp/, schema-driven HANDLER_REGISTRY)
  - S3 account-id suffix
  - CloudFront us-east-1 cert
  - ECS auto-cluster + task role
  - RDS / DocDB / Redshift no-default-password invariant
  - RDS provisioning poll
  - Lambda auto-build flow (git + npm + zip + bootstrap S3)
  - Bedrock on-demand no-op
  - Secrets Manager values-never-written contract
  - SQS / SNS .fifo suffix
  - SDK packages as optional peer deps
  - test harness layout
  - deferred work (VPC blocks, CodeBuild, drift detection, LocalStack)

Read this before changing any AWS handler.

* feat(aws): selectively enable safe categories via feature flags

Flip PROVIDER_FLAGS.aws.enabled to true with a hand-picked category
map (Storage, Messaging, Cache, Monitoring, Security, Source, Config).
Compute / Frontend / Scheduler / Network / Database / AI / Analytics
stay gated until their concrete unblockers land — ECS VPC blocks,
CloudFront cert-validation flow, update-paths, etc.

README.md gets a Rollout state table documenting why each gated
category is held back and what unblocks it.

Integrity test in packages/constants asserts the per-category map
stays exhaustive, so future CategoryId additions force a deliberate
on/off decision here.

* fix(palette): enable provider dropdown items when any block is available

Replace the project-provider lock on the palette provider dropdown
with an availability check: a provider option is selectable iff at
least one concept has it in providers and its category is enabled
for that provider.

Before: in a GCP project, AWS was greyed in the palette dropdown
even after AWS feature-flag enabled — so users couldn't browse the
AWS catalog from a GCP project.

After: AWS opens as long as it has any available block under the
current PROVIDER_FLAGS — drag-into-project compatibility remains
enforced at the canvas-drop layer.

availableProviderIds is derived in resource-palette.tsx from the
unfiltered component list using isCategoryEnabledForProvider — same
schema-driven gate the component filter already uses.

* docs(architecture): explain how canvas edges become cloud infra

New page docs/architecture/connections-to-cloud.md walks the five-layer
pipeline (connection-rules → propagation → type-maps → extractors →
handlers) and grounds it with two worked GCP examples:

- Storage.Bucket → Compute.BackendAPI: env-var injection + IAM binding,
  no edge resource in GCP.
- Compute.CronJob → Compute.BackendAPI: Cloud Scheduler HTTP target +
  run.invoker IAM binding.

Links the new page from architecture/README.md and the existing
core-engine.md "Computing flows" section so readers landing on either
find their way to the deep dive.

* fix(typecheck): unblock blocks + templates + core deploy-expansion

- deploy-expansion: use get_node_by_name for name-based dedup; has_node
  expects a branded NodeId, not a plain string.
- requirements.test: vitest needs `beforeEach` in the named imports
  (was previously globalised but tsc no longer sees it).
- validate.test: annotate `.map((c) => ...)` callbacks; vitest's bare
  `ReturnType<typeof vi.spyOn>` no longer carries the called-fn signature
  so the implicit-any error fires.

* fix(typecheck): unblock packages/ui + packages/web

Sweep the workspace's pre-existing typecheck errors so the whole repo
compiles cleanly under tsc --noEmit:

- packages/constants: re-export IntegrationStatus from the barrel so
  packages/ui/src/store/slices/integrations-slice.ts resolves.
- vitest type tightening: `vi.fn(() => ...)` infers Parameters as [],
  breaking `.mock.calls[i][j]` indexing. Widen to (..._args: unknown[])
  on the mock decls that get indexed (deploy-panel, requirements-
  section, deploy-diagnosis, inline-table-view, axios-instance,
  use-cost-calculation, use-computing-flows, template-picker,
  invite-accept).
- stopPropagation event mocks: vitest no longer accepts
  `{ stopPropagation: vi.fn() } as React.MouseEvent` without an unknown
  intermediate. Sweep through compact-node / custom-domain / group-node
  / log-node / palette / inline-table tests.
- KNOWN_MOCKS includes: cast through unknown[] in canvas-context-menu
  and inline-table-view test helpers.
- block-summary-card: import CanvasNode from ../../../svg-canvas (the
  path resolved one level too shallow).
- blueprints test: import BlockBlueprint from ../types not ../../types.
- group-node + region-label test fixtures: type 'container' (the
  CanvasNode union doesn't include 'group' or 'region' yet).
- reroute-node: switch sockets prop from SocketDef[] to PortDef[] with
  role: 'any' to match TypedSockets' contract.
- use-wizard-state: add missing 'name' field on EnvironmentPreset
  fixture.
- store/index.test: cast resolveFirst to its original closure type so
  optional-call type-narrows correctly.
- use-mouse-handlers test: drop minZoom/maxZoom (not in
  UseMouseHandlersDeps).
- deploy-diagnosis: widen the diagnosis state shape; replace
  setImmediate with setTimeout(resolve, 0) — Node's setImmediate isn't
  surfaced in the test types.

* docs(architecture): explain how canvas edges become cloud infra

* docs(readme): link AWS rollout state + connections-to-cloud page

- root README: sharpen "AWS — in progress" to mention the handler /
  extractor count and link the staged-rollout table in the AWS README.
- docs/README: add connections-to-cloud to the architecture mermaid
  and to the contributors table.

* docs(aws): reflect handler buildout + staged rollout

- provider-status: AWS matrix entry now lists the 17 handlers + 20
  extractors and the per-category feature-flag state instead of the
  stale "EC2 / S3 / Lambda only" copy.
- deploying-to-aws: drop the dead reference to "provider-status.md - to
  be added" (the doc exists now), fix the broken handlers-source link
  (handlers live at packages/core/src/deploy/providers/aws/, not
  packages/providers/aws/), fix the broken architecture.md anchor.
- Add a quirks section pointing operators at the AWS README for S3
  account-id suffix, CloudFront us-east-1 cert pin, ECS auto-cluster,
  RDS password gate + provisioning poll, Lambda auto-build, FIFO
  suffix. Update the "known gaps" list to match the deferred items in
  the AWS README (VPC blocks, CodeBuild, update paths, LocalStack).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant