feat: gate every user resource — compute, email, and search - #202
Merged
Conversation
The rules deciding which resources may carry .enabled() lived only in the compile-time check, so a caller that renders without preflights was guarded by each emitter's supports_enabled_when() opt-in alone. Moving the type and id rules into alien-core lets the setup generators enforce the same refusals at render time, which the generic gating post-pass needs before that opt-in can be deleted. The schema exporter now also writes a gateability manifest so the TypeScript SDK's builder surface can be asserted against the same table instead of drifting independently.
A gated resource's fragment can contain blocks the resource does not own — GCP custom roles and Azure role definitions are stack-wide, and the Azure ones land in whichever fragment the generator hands over first. The gating post-pass needs a signal that survives dedup-driven reordering, so fragments now record shared blocks by address instead of each call site special-casing block types.
Gating a resource previously meant its emitter threading the gate through every block and self-reference by hand, which is why only converted emitters could render .enabled(). The post-pass produces the same output from plain blocks: gate every owned block, rewrite references to gated addresses (AST traversals and parsed string templates) to count-indexed form, and refuse what cannot compose — pre-existing count, for_each, or a side-effecting block inside a footprintless residual. References hidden in raw strings the AST walk cannot see are caught by a rendered-output scan that fails generation on any unindexed gated address outside depends_on, where Terraform requires whole-resource references.
The generator now gates a fragment right after its emitter returns and rewrites gated references module-wide once every fragment exists, with the rendered-output scan as the final net. The per-type render refusal is replaced by the shared gateability policy, so render-without- preflights refuses exactly what the compile-time check refuses.
kv, queue, and storage across AWS, GCP, and Azure now emit plain, unconditional blocks; the generator's post-pass produces the identical gated render, proven by the unchanged gated snapshots. The one deliberate survivor is the GCP service-account emitter's cross-gate call: its project-wide grants follow the *target* resource's gate, which is contribution knowledge only that emitter has. supports_enabled_when() overrides are gone from the stripped emitters; the policy-refusal render test replaces the unconverted-emitter test, since 'unconverted' is no longer a state an emitter can be in.
…pt-in The CloudFormation generator already stamped the gate's Condition onto every emitted resource; the emitters' whole conversion was the supports_enabled_when() opt-in. That flag is now deleted from both setup-format traits — the shared gateability policy, re-checked at render time, is what refuses an ungateable resource. The SES inbound-delivery grant in the storage bucket policy becomes a gated contribution: it carries the Email contributor's gate (rendering byte-identically while Email is ungated), so the moment Email becomes gateable a declined Email takes its SES write access with it instead of leaving it in a sibling's policy.
The matrix walks the registry: every (type, platform) pair is either refused by the gateability policy or rendered gated from a fixture, validated with terraform validate, and asserted to splice its registration entry. A newly registered emitter for a gateable type fails the matrix until a fixture exists, so no type can silently become gateable. Its first run caught exactly that hole: network, remote-stack- management, kubernetes-cluster, service activations, and the Azure default-* auxiliaries were held back only by the deleted emitter opt-in, so the policy's stack-derived list now names them. Vault is the first type gated purely by the post-pass — no vault emitter ever carried gating code — locked by per-cloud snapshots. The Azure snapshot shows the residual allowlist reproducing the deliberate ungated random_id naming suffix.
The CloudFormation matrix mirrors the Terraform one — every registered emitter is policy-refused or rendered gated, resolved through the deploy-time model to prove a declined fixture leaves no registration entry — plus a render-side refusal test for a gated Email, proving the policy holds without preflights. The gated vault render is locked by a snapshot; on AWS a granting-free vault contributes no template resources at all, so its gated render is exactly the registration splice. The TypeScript surface test reads the generated gateability manifest and asserts every policy-allowed type offers .enabled() and every refused type does not, so the SDK and the Rust policy cannot drift.
From review:
- splats over a gated address stay unindexed — a splat is already
list-aware, and inserting the count index would splat one instance
and break when the gate is off
- the rendered-output scan accepts only real instance indexes (digits
or [*]) after a gated address, so a key index cannot escape both nets
- the depends_on exemption requires the attribute form 'depends_on = ['
— the word in prose no longer exempts whatever bracket follows
- template %{if}/%{for} directives and expression-valued object keys
are rewritten recursively
- the scan's refusal is OperationNotSupported (non-retryable): the same
stack renders the same escape every time, so retrying is a loop
- the CloudFormation parameter-collision message renders without the
continuation-indentation space runs, with tests for both formats'
collision guards
- registered_keys() no longer sits between require() and its doc
comment; the SDK surface test covers the refused-type builders
(Daemon, Container, Email) explicitly
The vitest transform accepted the extensionless import and the two-argument Worker construction; tsc under nodenext does not.
Contributor
Author
|
Real-cloud verification: the enabled-demo e2e (four gated store pairs + the gated worker pair + baseline service accounts) runs at https://github.com/alienplatform/alien/actions/runs/30128396618 on these exact commits. |
Greptile SummaryThis PR extends resource gating across compute, email, and search while preserving frozen answers and reversible live-resource baselines.
Confidence Score: 5/5The PR appears safe to merge within the scope of the previous review threads. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| crates/alien-deployment/src/pending.rs | Implements frozen-answer resolution, legacy reconstruction, fixity enforcement, and lifecycle-aware decline stripping; the displayed follow-up issues are addressed without an eligible distinct failure. |
| crates/alien-deployment/src/updating.rs | Applies frozen checks and stripping before mutations while handling live gates afterward; no blocking incomplete or unsafe fix remains within the previous-thread scope. |
| crates/alien-manager/src/routes/stack.rs | Reconstructs and validates frozen answers during re-registration, including guarded handling when the settled release is unavailable. |
| crates/alien-core/src/deployment/state.rs | Adds backward-compatible persisted frozen-gate answers to runtime metadata. |
| crates/alien-core/src/gateability.rs | Expands lifecycle-aware gateability policy to the newly supported user resources. |
| crates/alien-terraform/src/gating.rs | Adds infrastructure-level gate propagation used by generated Terraform resources and grants. |
Reviews (14): Last reviewed commit: "docs(deployment): legacy acceptance stil..." | Re-trigger Greptile
The gateability policy drops its compute arm — declining a live workload rides the same removal path as deleting it from a release, and its provisioning baseline persists so acceptance can return — and empties the not-yet-generic list now that email and opensearch render gated through the post-pass (proven by their new matrix fixtures; the SES inbound grant demonstrably follows the Email gate, and a Live gate is proven invisible to setup while its input still reaches the deployer). Worker, Daemon, Container, Email, and the experimental AwsOpenSearch builders gain .enabled(); the regenerated manifest keeps the SDK surface test honest about which lifecycles each type gates in.
The initial path stripped declined resources after the mutations while the update path stripped before them, so declining a live resource on an update rederived service accounts, profiles, and capacity from a stack missing it — tripping the frozen-compatibility check that the initial path's ordering never trips. Both paths now agree: frozen declines apply before the mutations (a declined setup resource never existed, so nothing may be derived from it), and live declines apply after them, at the boundary where the executor's desired set is built, so a declined workload's provisioning baseline stays identical to the accepted render and acceptance can return. Proven by the worker decline/reaccept state-machine test. A frozen gate on a direct deployment now resolves the initial input values (provided value, else declared default, never a guess); the empty state previously carried no answer and the resource was created regardless of the deployer's choice. The manager's import route composes the two strips back to back — no mutations run between them there.
The canonical resolved answer for every input gating a Frozen resource is recorded on the runtime metadata when the deployment is created — the same record/state/sync surface that already carries the prepared stack, so no store or protocol shape changes. The update path refuses input values that conflict with a recorded answer; states from before this contract derive their answers from the settled stack state (presence IS the original answer once setup completed) and upgrade atomically before the check, refusing rather than guessing when resources sharing one gate disagree. Deployment protocol CURRENT moves to 2 (MIN stays 1): an actor unaware of the fixity contract would step a deployment against the deployer's recorded answer, so older actors refuse newer states instead. Live-gate transitions now emit a structured audit event when detected, with an operation id derived from resource, input, answer, and release — identical across retries of the same step by construction, fresh for a later flip — completed or failed by the executor's ordinary per-resource status transitions. The preflight gains the pause-consumer contract tests: a gated worker consuming an ungated queue passes (the queue's retention policy governs the backlog), while an ungated worker consuming a gated queue stays refused.
The import route now resolves every frozen-gating input's answer before the strip and records it on the created deployment, so imported deployments carry their answers from birth instead of deriving them on their first update. A re-registration whose derived answers conflict with the recorded ones is refused synchronously with FROZEN_GATE_ANSWER_CHANGED — and because the caller is the setup artifact's custom resource mid-stack-update, the failed response is what forces CloudFormation to roll the parameter edit back, restoring whatever its conditionals just created or deleted. The refusal runs before setup-update authorization is minted, so an answer flip cannot ride an otherwise-authorized setup update; answers for inputs a new release introduces are recorded, and recorded answers are never overwritten.
An additive optional field does not bump the version. Re-derived from first principles, the bump bought nothing — an older actor's strip already resolves frozen presence from state, so a flipped input is ignored rather than applied, and answers dropped by an old write-back are rebuilt faithfully by the derive-when-empty fallback — while costing a lot: pull agents upgrade on the customer's schedule, and a version-2 state would hard-refuse every older agent the moment a newer manager writes one. New actors refuse conflicting answers loudly; older actors keep the silent-ignore behavior they always had. Nothing can flip a frozen resource either way.
Adds a distribution test app with a worker plus a matched on/off pair of every gated resource type (KV, storage, queue, vault) behind .enabled(input). The test answers the four *-on inputs true and the four *-off inputs false, provisions to AWS via Terraform, then asserts each enabled resource and its grant reach the imported stack_state and the account while each declined resource is absent. Wires TestApp::EnabledDemo through the harness (input_values -> tfvars) and adds the terraform-aws matrix entry to e2e-cloud.yml behind an enabled-demo app filter.
The compute gate rides the live strip, so the real-cloud proof is the pair pattern the app already uses: the accepted worker's function must exist while the declined worker's function is never provisioned — and both dedicated profiles' service accounts must exist, the persisted baseline that lets a later acceptance recreate the function without a setup change. Neither gated worker links anything, so no grant depends on them.
A legacy re-registration could adopt flipped answers through its empty recorded map; the settled state is the ground truth, so the import route now derives the baseline from it before comparing, and the flip is refused the same as everywhere else. An input whose last frozen resource left the release stayed frozen forever; fixity now scopes to inputs that still gate a setup-created resource in the declared stack, so freeing the input frees its answer. An update omitting a shared input resolved the live gate straight to the declared default, deprovisioning an accepted workload; the live strip now resolves provided value, then recorded answer, then default — frozen dominance for the omitted case.
terraform validate cannot tell an accepted render from a declined one — the gate variable is unknown at validate time, so count is unknown and both branches type-check. The matrix now checks every rendered block: one that reads the gated resource's indexed address must carry the gate itself, or the declined apply resolves an index into an empty list. A type that owns no blocks (a name-prefixed vault) is allowed, since dropping its registration entry is its whole gate. A second test holds the deployer input values output and secret inputs together: that output publishes every answer in the clear, which is only safe while secret inputs cannot reach the module at all. Also sentence-joins the generated input descriptions, which ran label and description together in variables.tf, drops past-tense narration and a stray space run from two test doc comments, and reframes the protocol-version note as the invariant that must hold.
ItamarZand88
force-pushed
the
itamar/alien-330-generator-owned-gating
branch
from
July 25, 2026 00:36
7b91f28 to
726a475
Compare
ItamarZand88
force-pushed
the
itamar/alien-330-gate-all-resources
branch
from
July 25, 2026 00:36
2ceea07 to
2444066
Compare
Routing build() through the base class left Resource used only in return positions, which the lint gate requires be imported as a type.
The live strip consulted the recorded answer for any input that had one, including an input a later release stopped gating a frozen resource on. A recorded no then overrode the release's declared default and deprovisioned a live resource the release actually enables. Dominance now applies only while the input still gates a frozen resource — which is what it means. Once freed, a live gate resolves like any other: provided value, else the declared default.
The audit's value_source said 'persisted' whenever a recorded answer existed for the input, but the resolver only consults one while the input still gates a frozen resource. A freed gate carrying a stale recorded answer therefore resolved from the declared default while the log blamed the recorded one — on exactly the delete and create transitions the audit exists to explain. Also states the live strip's real resolution order on its doc comment, and records what the legacy derivation gives up by refusing to infer a decline from absence.
Resources gated on one input share a policy, and each contributes the same permission set, so their statements arrive carrying the same id. IAM rejects a policy document that repeats one, and two ordinary workers with commands enabled are enough to hit it — the profile mutation derives the dispatch set per resource independently. Every other place that merges statements into one document already normalizes the ids; this path was the only one that did not.
Azure folds every resource-scoped management permission set into one role assignment, so the grant belongs to all of its contributors at once. It now carries the OR of their gates and disappears when the last one is declined; a single ungated contributor keeps it unconditional, because that resource still needs it. The runtime built the same grant from the management profile alone, and the profile is authored before the declined-resource strip and never pruned by it — so it would have restored exactly what the template now withholds. It now skips profile entries naming a resource that is no longer in the desired stack, which is how AWS already reads a decline. The role definition stays ungated: a definition nothing is assigned to grants nothing. The limit is written down where the gate is applied — these grants are resource-group scoped, so declining one worker while a sibling stays enabled cannot revoke it.
A gated frozen resource was dropped when it was missing from the settled state. That reads absence as a decline, and absence has two causes: the deployer said no, or the release being deployed just introduced the gate. Conflating them meant a release that adds a gated frozen resource had it deleted from the prepared stack and the update reported success — while the same release adding an UNGATED frozen resource got the clear refusal telling the operator to rerun setup. Gating a resource turned an actionable error into silence. The strip now has one rule on every path: a resource leaves if and only if its gate's recorded answer is no. Resolving those answers is where the evidence lives, and each path has evidence that is complete on its own — the deployer's input values on a direct deployment, the delivered resource ids on a setup import, the recorded answers on an update. A gate with no answer is not a decline; it is a gate nobody was asked, so the resource stays and the frozen-compatibility check says what it always said for ungated resources. Deployments that predate recorded answers reconstruct them instead of being special-cased. Presence in the settled state proves acceptance on its own. Absence only means a decline for a gate the settled release actually declared, so that release is read alongside the state — which needs the deployment's current release to carry the settled stack, and in push mode the manager was filling it with the target's. It now loads the settled release, the way the pull path already did.
AWS emitted resource-scoped management grants by resource name and needed gating; Azure merged them into one assignment and needed an OR gate. GCP needs neither, and this records why rather than leaving it inferred: its resource-scoped management path handles only Kubernetes clusters, a type the gateability policy refuses, so a gated workload contributes no management grant to begin with. The test asserts both halves — nothing names the gated worker today, and anything that ever does must carry its gate.
…lls on Six functions each re-derived whether a gated resource is setup-created, by hand, with the sense inverted in half of them. That classification now lives in one pair of iterators, so a change to what counts as frozen has one place to go instead of six. The live gate's resolver returns where its answer came from, replacing a second function that restated the same precedence rule and carried a comment promising to mirror it — the audit log can no longer describe a precedence the resolver did not apply. The frozen strips get one ordered entry point. Their order is load-bearing (the dominance pass needs the gating inputs computed before the frozen strip removes the entries that define them) and was previously kept by prose at three call sites. GCP's binding gate asks the fragment whether a block is shared instead of re-testing the provider type name the fragment already classified. The manager holds the settled release's stack per deployment rather than refetching and re-deserializing it on every tick of a running update.
…luded A grant rendered on behalf of a resource the deployer can decline was gated by the emitter that rendered it — three times, once per cloud — because such a grant belongs to a resource other than the one whose fragment it lives in, and the post-pass only ever sees a gated resource's own fragment. Emitters now declare those blocks the way they already declare shared ones, and the generator installs the count. The clouds keep the shapes they need: AWS one gate per policy, GCP a range of just-appended bindings, Azure several gates on the one assignment it merges its contributors into. That difference is now data — how many inputs a block declares — rather than three implementations. The reason to do it is the address bookkeeping. Blocks gated inside an emitter never entered the generator's gated-address set, so neither the reference rewrite nor the rendered-output scan knew about them; the GCP helper documented this and named what made it safe — those blocks are leaves nothing references — while the two added by this branch did not. The invariant also could not hold for extension emitters outside this crate, which is exactly who that helper is public for. Declared contributions are registered, so the nets cover them. Applied in the emit loop rather than a later pass: the GCP dedup folds sibling counts together and needs them already installed. Rendered output is unchanged — every snapshot is byte-identical.
The strip decision is cloud-agnostic and the gated render is linted by terraform validate on all three providers, so only the applied declined state would be new coverage. Same reasoning the file already applies to the command-routing app.
Member
…prove Reconstructing a legacy deployment's frozen gate answers reads acceptance from the settled state, which needs no release, but a decline is only legible against the release that offered the gate. A missing release and one carrying no stack for the platform were both collapsed into "no settled stack", so a resource the deployer had declined could be accepted again in silence. The contradiction the settled state can prove is still checked first, so a flipped acceptance keeps naming its input instead of blaming the release.
…rator-owned-gating # Conflicts: # crates/alien-terraform/src/emitters/gcp/kv.rs
…ien-330-gate-all-resources # Conflicts: # crates/alien-deployment/src/pending.rs # crates/alien-deployment/src/updating.rs
…-all-resources # Conflicts: # crates/alien-cloudformation/tests/generator/gating_matrix_tests.rs # crates/alien-core/src/gateability.rs # crates/alien-terraform/src/emitter.rs # crates/alien-terraform/src/emitters/enabled.rs # crates/alien-terraform/src/gating.rs # crates/alien-terraform/src/generator.rs # crates/alien-terraform/tests/generator/gating_matrix_tests.rs # crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_vault_aws.snap # crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_vault_azure.snap # crates/alien-terraform/tests/generator/snapshots/generator__generator__helpers__enabled_gated_vault_gcp.snap # crates/alien-terraform/tests/generator/stack_input_tests.rs # packages/core/src/__tests__/gateability.test.ts # packages/core/src/generated/gateability.json
Contributor
Author
|
@greptile-apps review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.enabled()now covers every user resource: compute gates as a live existence choice, email and search gate at setup — each admitted with zero type-specific gating code, which is what the generator-owned mechanism exists for. This PR also pins the semantics: a frozen gate is answered once and enforced for the deployment's lifetime, and a declined live workload keeps its derived baseline so acceptance can return.What happens when a deployer changes a gate's value on an update:
FROZEN_GATE_ANSWER_CHANGED); a re-registration that flips one fails synchronously, so a CloudFormation parameter edit rolls the whole stack update back.This PR changes the gateable set from four store types to every user resource, with frozen answers fixed and live answers reversible.
What was broken
What I did
.enabled(), held to the policy by the generated manifest.Files touched
crates/alien-core/src/gateability.rs,deployment/state.rs— policy widening;GateAnswerson the runtime metadatacrates/alien-deployment/src/{pending,updating,lib,error}.rs— the split strip, answer resolution and enforcement, audit events, typed refusalscrates/alien-manager/src/routes/stack.rs,tests/stack_import.rs— import-time recording and the synchronous re-import refusalcrates/alien-preflights/src/compile_time/resource_enabled_valid.rs— compute ban removed; pause-consumer tests both directionspackages/core/src/{worker,daemon,container,email}.ts,experimental/aws-opensearch.ts, tests, manifest — the SDK surfacecrates/alien-deployment/tests/test_platform.rs— worker decline/re-accept baseline test; frozen-answer lifetime testtests/e2e/test-apps/enabled-demo/*,crates/alien-test/{src,tests}/distribution.rs— the gated worker pair and its cloud assertionsHow I tested
cargo test -p alien-deployment --test test_platformdrives the full cycles — a live worker declined and re-accepted across updates with its service account asserted present in the prepared stack throughout, and a frozen answer recorded, refused on conflict, accepted on match, and still refused after wiping the recorded answers to simulate an older deployment.cargo test -p alien-manager --test stack_import— a fresh import records the declined and accepted answers; a re-registration that flips one gets a synchronous 400 withFROZEN_GATE_ANSWER_CHANGED.tsc.terraform_aws_push_enabled_demoeach failed during image upload with an ngrok gateway error (Service Unavailable, before Terraform ran — no gating code in the path, no cloud resources created), so the run is dispatched on this branch in the cloud e2e workflow with theenabled-demoapp filter; the PR should merge on that run's green.I also ran a security review on the diff. What it checked:
Nothing turned up.
Out of scope