carlos / platform Public

Preserve lifecycle intent without enabling residency

claude agent, operated by paul@keymail.dev 2e489fd1b650a130aac8f06eeadab0be323a8522 parent 0797d9b
7 files changed, +402 −27
  • implementation/always-awake-exec.md +27 −17
  • internal/console/apiinstances.go +9 −6
  • internal/console/lifecycle_views_test.go +34 −0
  • internal/provision/lifecycle.go +49 −0
  • internal/provision/lifecycle_test.go +247 −0
  • internal/provision/maintenance.go +31 −1
  • internal/provision/provision.go +5 −3

CI passed — run details

diff --git a/implementation/always-awake-exec.md b/implementation/always-awake-exec.md
index 140339482..a784ec7d6 100644
--- a/implementation/always-awake-exec.md
+++ b/implementation/always-awake-exec.md
@@ -4,6 +4,16 @@ Status: design proposal for LLM implementation and independent review. No code o
Base: platform `11a303996d5503d29ede77598243ddc66f735279`, which merges the Fable-approved inert maintenance components e87b410. Home requirement: `7e282e2a363b9cc90cd74dd97809e6613a699af8`, `docs/superpowers/specs/2026-08-30-home-design.md` §2.1 and AGENTS.md; both are supplied with the review bundle. Maintenance design `6a81dac`, `implementation/maintenance-exec.md`, is approved. Guard/claim/RecoverClaim, Held lease, strict containment, local fence and scheduled-token primitives now exist in this base. They are unmounted components: the maintenance worker, browser approval workflow, preflight, end-to-end resume (including proposed ResumeFenced), checker launch adapter and enforced platform floor are NOT implemented. References below to the maintenance workflow mean that approved/proposed integration contract, not an existing executable service. No component approval proves deployment capabilities.
+## Paid-launch encoding amendment, 2026-10-04
+
+This amendment changes the proposed wire encoding to explicit `hibernate:true` plus `lifecycle:"always-awake-exec-v1"`. The boolean retains existing activator and replication ownership; a future validated lifecycle consumer overrides idle sleep. The earlier false-Hibernate encoding is withdrawn. The Home activation protocol below remains a proposal, with its existing authority and recovery gates; this amendment does not approve or implement that protocol.
+
+The currently reviewed implementation unit only preserves and validates the field in `provision.Record`, protects it across ordinary record writes and omits it from customer API views. It has no installer, registry field, resident controller or billing authority. Ordinary region, move and compatible channel edits preserve an unchanged lifecycle value and continue to succeed. Refusing those final writes after earlier placement or move effects is unsafe. Install, clear, replacement and contradictory hibernate changes are refused. Runtime-era restrictions on move or execution-class changes require separately reviewed checks before any preceding effect; they are not supplied by this field-preservation unit. Valid v1 records still project the same legacy hibernating route in this unit. Malformed recognized records remain held by reconciliation, and complete steering discovery refuses them before DNS reconciliation. Steering remains dormant; this is not evidence of deployed protection.
+
+Carloku requires explicit account overage opt-in, default OFF, and a per-app estimate before enabling Always awake. Free has 60 pooled awake hours per month; Personal has 100 hours in the base band with existing paid-band scaling. These decisions do not authorize exhaustion behavior, retained-storage charges, proposed USD/GBP rates, or discarding prior obligations. The Home existing-instance proposal below does not complete Carloku signup/account controls, opt-out, new-instance support or resource enforcement.
+
+The encoding and bounded field-preservation unit received independent Opus review. Registry projection, admission/recovery rules, the resident controller, the old-writer and runtime floor, and production/provider proof remain separate gates. Source compatibility preserves ownership; it neither enforces a spending limit nor makes an older binary maintain residency.
+
## Required behavior
Home stays running without incoming traffic. The platform starts an eligible Home process at boot, keeps it through idle periods, and proactively recovers it after an ordinary crash or adoption cycle. Maintenance, lost ownership, shutdown and an unproven cleanup still stop it. A failure can cause an explicit outage; “always awake” does not promise active-active availability or zero downtime. It means no deliberate idle sleep and no dependency on a visitor to recover service.
@@ -24,9 +34,9 @@ Do not approximate residency with requests, timers, scheduled jobs, dummy sideca
## Record shape and public policy
-Add optional string `lifecycle` to `provision.Record` and the registry route projection. The only nonempty value in this slice is `always-awake-exec-v1`. Absent/empty means the existing behavior byte for byte. Unknown versions refuse before route materialization or startup; they never degrade to an unmanaged proxy.
+Add optional string `lifecycle` to `provision.Record` and the registry route projection. The only nonempty value in this slice is `always-awake-exec-v1`. Absent/empty means the existing behavior byte for byte. Unknown versions refuse record decoding and writing. In the bounded field-preservation unit a valid v1 record is data only and projects as the same legacy hibernating route; no residency or new startup refusal is implemented. Future runtime admission and recovery must define their refusal contract before activation.
-A record carrying this value must also carry explicit `hibernate:false`. The two fields are written by one record CAS and materialized together in one registry transaction. Contradictory `hibernate:true` refuses. The new field describes a real managed lifecycle; the false flag accurately reports that the instance does not hibernate.
+A record carrying the nonempty value must also carry explicit `hibernate:true`. Contradictory false, null or absent hibernate refuses. The future installer writes both fields in one record CAS; registry projection remains a separately reviewed unit. The lifecycle names the proposed residency override while the boolean preserves the existing managed ownership class. Raw hibernate is therefore not a complete user-facing description of sleep policy.
Required eligibility: an existing provisioned `instance`; derived canonical local Unix socket and nonempty canonical primary database path; ordinary exec backing; exactly one verified residence; ready status.
@@ -55,18 +65,18 @@ Every record writer preserves the lifecycle field as well as the maintenance pro
Define one shared pure validation result from a decoded route policy. Its concepts are:
-- `ManagedByActivator`: existing hibernating instance shape, or eligible explicit always-awake exec shape.
+- `ManagedByActivator`: preserve the existing management selectors for legacy routes and for eligible v1 routes, whose Hibernate remains true. Different existing selectors are not interchangeable merely because they mention Hibernate.
- `MayIdleSleep`: existing hibernating behavior only; false for the new policy.
- `OwnsExecReplication`: validated activator-managed exec backing AND nonempty canonical `DBPath`, including the new policy; unit backing remains excluded. The path condition remains mandatory in both `ops.FleetDBsWithSkipped` and `ops.HibernatingHosts`. A malformed new-mode route refuses; a legacy route with no database stays outside this predicate.
- `RequiresResidentProcess`: explicit always-awake exec policy only.
-Concrete Go names may follow local conventions, but no consumer may reconstruct these with its own `!Hibernate` rule. Malformed/unsupported policy returns a refusal, not four false values. A legacy false-hibernate socket remains externally managed and must not suddenly acquire a child.
+Concrete Go names may follow local conventions, but no consumer may derive `MayIdleSleep` or `RequiresResidentProcess` from Hibernate alone. Malformed/unsupported policy returns a refusal, not four false values. A legacy false-hibernate socket remains externally managed and must not suddenly acquire a child.
-The registry update must preserve the replication rung. Existing `Registry.SetHibernate(false)` executes `hibernate=0, replication=''`; calling it during this transition is wrong. Add a dedicated checked transactional projection of lifecycle+hibernate with the existing replication value preserved, or extend the canonical projection transaction to do exactly that. Do not alter legacy SetHibernate behavior for unrelated routes. The invariant is `Replication != ""` implies `OwnsExecReplication`, rather than implies Hibernate. Apply this invariant to BOTH Registry.Upsert INSERT normalization and its ON CONFLICT replication expression, as well as the dedicated projection transaction. Update `replicationRail` and `carlos add` validation/defaulting to the same predicate; neither may accept a rung for an empty DBPath or externally managed route. Preserve the existing rung when an eligible resident route is re-upserted with no new rung. Reconcile’s existing-owned branch must use the dedicated projection instead of SetHibernate(false), including after record CAS succeeded but projection failed.
+The registry update must preserve the replication rung. Existing `Registry.SetHibernate(false)` executes `hibernate=0, replication=''`; calling it during this transition is wrong. Add a dedicated checked transactional projection of lifecycle+hibernate with the existing replication value preserved, or extend the canonical projection transaction to do exactly that. Do not alter legacy SetHibernate behavior for unrelated routes. Keep the existing Hibernate-based ownership and replication-rung behavior: v1 retains true Hibernate. Do not broaden Registry.Upsert, replicationRail or carlos add eligibility solely for this encoding. A future checked lifecycle projection must preserve the existing rung and identity on INSERT, conflict and repair, without an intermediate false-Hibernate state. Registry storage, setters and admission remain deferred.
-Before `convergeOwned` or ANY owned-row field update in Reconcile, compare the authoritative record lifecycle with the existing row. A row already carrying v1 paired with an absent, unknown or conflicting record lifecycle is an invalid regression, including a stripped legacy record. Quarantine it: preserve the entire v1 row and replication ownership, install a distinct local `policy-regression` admission/start denial keyed to the exact primary identity and observed record/row revisions, and cancel pending starts. This is a new denial reason, not a fabricated maintenance generation. Reconstruct it from the persisted row plus authoritative mismatch before admission after restart; normal maintenance resume cannot clear it without resolving the exact mismatch through its approved projection. Keep that primary in the activator-owned retention set so Edge.Reload/RetireExcept cannot reinterpret it as external ownership. Stop any running main/sidecar and replica through the checked stop path; retain uncertain resources and deny replacement on failure, with no premature lease release. Even after a proven stop, retain the row so root replication continues to exclude its DB. Record/row disagreement supplies neither ordinary startup nor a policy repair permission; repair requires the exact approved maintenance operation. This rule differs from temporary nonauthoritative discovery failure, which does not fabricate a regression or retirement.
+The following runtime-era refusal contract is proposed and requires a separate reviewed implementation; it is not authority to add a duplicate denial journal in the field unit. Before `convergeOwned` or ANY owned-row field update in Reconcile, compare the authoritative record lifecycle with the existing row. A row already carrying v1 paired with an absent, unknown or conflicting record lifecycle is an invalid regression, including a stripped legacy record. Quarantine it: preserve the entire v1 row and replication ownership, install a distinct local `policy-regression` admission/start denial keyed to the exact primary identity and observed record/row revisions, and cancel pending starts. This is a new denial reason, not a fabricated maintenance generation. Reconstruct it from the persisted row plus authoritative mismatch before admission after restart; normal maintenance resume cannot clear it without resolving the exact mismatch through its approved projection. Keep that primary in the activator-owned retention set so Edge.Reload/RetireExcept cannot reinterpret it as external ownership. Stop any running main/sidecar and replica through the checked stop path; retain uncertain resources and deny replacement on failure, with no premature lease release. Even after a proven stop, retain the row so root replication continues to exclude its DB. Record/row disagreement supplies neither ordinary startup nor a policy repair permission; repair requires the exact approved maintenance operation. This rule differs from temporary nonauthoritative discovery failure, which does not fabricate a regression or retirement.
-`ops.FleetDBsWithSkipped` currently excludes exec DBs only when `Hibernate` is true. Replace that exclusion with `OwnsExecReplication`; otherwise the root replicator may pick up a database already streamed under the activator lease. The generated fleet replication config must be checked before activation, and missing/unreadable ownership must refuse a potentially duplicate replication change. This mode does not rename `HibernatePrefix` or move existing objects.
+`ops.FleetDBsWithSkipped` already excludes the relevant true-Hibernate exec DBs. Preserve that exclusion and its existing path/backing conditions; this encoding requires no root ownership widening. The generated fleet replication config must be checked before activation, and missing/unreadable ownership must refuse a potentially duplicate replication change. This mode does not rename `HibernatePrefix` or move existing objects.
## Proactive resident reconciliation
@@ -104,7 +114,7 @@ An executing, failed, ambiguous or resume-requested maintenance record never bec
This section is a NEW proposed maintenance action amendment. It is not an app-exec operation and is not already authorized by maintenance6a. It makes activation concrete without adding another `maintenanceguard.OperationKind`: the existing `MaintenanceOperation` app claim provides exclusion, while a separately versioned typed request selects this fixed platform action. Fable must approve both its authority boundary and its implementation before it is exposed.
-Add a closed request discriminator for `platform-residency-transition-v1`, distinct from app-command requests. Reject unknown action/version and every command, argv, executable path, raw config, environment override or caller-supplied record patch in this variant. Its inputs are exact account/app/primary host, immutable request ID and the platform preflight reference. The platform derives the only allowed application-field delta from the approved old record: `lifecycle="always-awake-exec-v1"`, `hibernate=false`. Identity, paths, region, replication class, channel, environment, credentials and all other record data remain unchanged. Maintenance projection updates are additional protocol metadata, not part of the two-field policy delta.
+Add a closed request discriminator for `platform-residency-transition-v1`, distinct from app-command requests. Reject unknown action/version and every command, argv, executable path, raw config, environment override or caller-supplied record patch in this variant. Its inputs are exact account/app/primary host, immutable request ID and the platform preflight reference. The platform derives the only allowed application-field delta from the approved old record: `lifecycle="always-awake-exec-v1"`, explicit `hibernate=true`. Identity, paths, region, replication class, channel, environment, credentials and all other record data remain unchanged. Maintenance projection updates are additional protocol metadata, not part of the two-field policy delta.
The platform verifies its own runtime capability/floor for this action. An app manifest's advertised maintenance command does not authorize it, and it does not dispatch through the app-command executable path. The approved existing app artifact/config remain pinned for restart. Existing required app semantic contracts are preserved unchanged; this action cannot lower any guard or binary floor.
@@ -117,7 +127,7 @@ The worker performs the following ordered protocol:
3. Quiesce through the existing local fence. First prove original app and sidecar shutdown, old replica closure and kernel group emptiness through the reviewed maintenance protocol. The old normal replica owns its lease release; do not acquire the maintenance Held lease before that closure is proven. Only after those proofs acquire the exclusive maintenance Held lease, recheck authoritative owner/guard/record and retain it for the transition. An old lease still held is a refusal to proceed, not permission for parallel ownership. No proactive resident loop, root adoption, config sync, scheduler, socket fallback or ordinary request may start a process. Recheck exact guard/claim/record and app state under that authority. In the source class, the record still has its old hibernating policy.
4. Persist executing before changing policy. This is a platform record transition with no app subprocess, shell, SQL migration, seed or restore. The existing checked checkpoint/integrity requirements still protect the stopped database. The typed handler performs one CAS from the current exact guarded record to the derived target fields while preserving its current maintenance projection and every other field. A conflict does not retry against a newly read arbitrary record.
5. Read back the actual resulting record revision and compare all protected fields to the approved snapshot. Publish an immutable action receipt naming original identity, approved request/delta, pre/post record revisions, target policy, current claim/guard generation and the checked stop/lease evidence references. Sensitive snapshot material remains in the existing private envelope; public status contains no environment or DB contents. Receipt publication failure is ambiguous and leaves the fence held.
-6. Materialize the target lifecycle and false Hibernate in one checked registry transaction while still fenced, preserving DB/socket/owner/replication columns. It must be the projection of the exact target record revision, not a freestanding local edit. Read back and verify it. No ordinary manager adoption can open admission. A stale projection is a repairable incomplete action only under the exact operation recovery below.
+6. Materialize the target lifecycle and explicit true Hibernate in one checked registry transaction while still fenced, preserving DB/socket/owner/replication columns. It must be the projection of the exact target record revision, not a freestanding local edit. Read back and verify it. No ordinary manager adoption can open admission. A stale projection is a repairable incomplete action only under the exact operation recovery below.
7. After matching target record/registry state and the normal final checkpoint/receipt requirements, publish succeeded with immutable action evidence. Successful policy installation remains fenced. Report that state; do not release the app claim or automatically start Home.
8. Explicit fresh owner-approved resume validates the target policy under the amended eligible class and the exact target record, selected serving release/config, capability floors and action receipt. Before serving, run the fresh app-specific non-serving compatibility checker from that selected release under the fence and exclusive lease, exactly as maintenance6a requires. Bind its successful receipt to this resume attempt, selected artifact/config and current database/record identity; complete its normal Wait and checked process-tree cleanup. The policy action itself still invokes no app subprocess, but resume retains this separate mandatory checker. Only then start the approved serving app under the retained exclusive lease and checked resume protocol with admission closed. The accepted ResumeRequested guard MUST carry a nonempty sorted unique lowercase RequiredContracts set, derived from the exact selected Home release’s verified signed manifest and preserving all already-required contracts. The same set is bound to fresh owner approval and remains pinned through Resumed. At least one selected-release contract is mandatory; no available verified contract means resume refuses before any start. This action invents no unsigned contract or capability name. Verify health/version, publish the matching resumed guard and record projection, verify both, then open local admission and release the matching app claim only after serving health agrees. Install resident desired state as part of that handoff; it cannot precede or replace it.
@@ -131,7 +141,7 @@ The table below applies only while the SAME live executor retains its valid excl
Claims never expire. `RecoverClaim` observes only the exact operation ID; status/GET is read-only. A new random request cannot take over an interrupted operation. Stopping/checkpointing cleanup under an accepted guard follows maintenance6a's existing limited recovery rule; it does not automatically apply the policy CAS or start an app.
-Persist immutable expected old and target application-field snapshots excluding only the maintenance projection metadata managed by the protocol. Compare every other record field, including unknown preserved fields, against one of those snapshots on recovery. Do not infer success from `hibernate=false` alone. Record ETags must still be compared for each subsequent write. The implementation must use one strict canonical record representation for these protected comparisons and refuse unsupported/ambiguous encodings, not drop fields during normalization.
+Persist immutable expected old and target application-field snapshots excluding only the maintenance projection metadata managed by the protocol. Compare every other record field, including unknown preserved fields, against one of those snapshots on recovery. Do not infer success from `hibernate=true` alone; it also describes legacy managed routes. Record ETags must still be compared for each subsequent write. The implementation must use one strict canonical record representation for these protected comparisons and refuse unsupported/ambiguous encodings, not drop fields during normalization.
| Observed recovery state | Permitted action (same executor and retained resources only) |
| --- | --- |
@@ -159,7 +169,7 @@ The remaining recovery implementation is bounded by existing maintenance lease d
## Activation and rollback
-Activation is blocked until every record writer, serving box, edge, agent, root replicator/adopter and relevant recovery binary understands this policy and enforces its supported rollback floor. Additive JSON alone is not safe: an old writer strips lifecycle, an old edge treats false-hibernate as external ownership, and an old replication scanner can add a second replicator. An old route reader may also preserve stale hibernation state during partial conversion.
+Activation is blocked until every record writer, serving box, edge, agent, root replicator/adopter and relevant recovery binary understands this policy and enforces its supported rollback floor. Additive JSON alone is not enough: an old writer strips lifecycle, and an old runtime can idle-sleep or fail to restart the process proactively. Explicit true Hibernate preserves the existing activator ownership and root replication exclusion for old readers; it does not enforce residency, consent or spending limits. The floor must cover writers as well as readers, and may require a later version than the field-preservation unit.
The signed app runtime-contract design is a separate pending dependency and does not itself define a platform binary floor. A distinct authenticated platform-floor design is still required. Independently, the reviewed signed app runtime-contract mechanism gates step 8 and EVERY subsequent wake: CheckServing must receive the exact verified manifest contracts and find the pinned RequiredContracts set. An unsigned self-report, only a platform floor, or an empty set cannot satisfy the Resumed guard. This design requires a distinct platform capability identifier for always-awake exec support in that reviewed mechanism; do not repurpose an app's maintenance semantic contract as a platform binary capability or trust a self-reported unsigned capability. Floors must precede both policy-writing activation and host/replication activation, even if no maintenance operation is enabled. Until a reviewed authenticated floor and its deployment proof exist, this mode remains disabled. This proposal does not define a new signing format or mark that dependency approved.
@@ -169,13 +179,13 @@ After activation, old binaries cannot be an availability rollback. Roll back onl
## Capability dispatch and checker launch
-Carry `ManagedByActivator` and `RequiresResidentProcess` as explicit validated fields in `caps.Plan` from capabilityPlan and every other plan constructor. Before filesystem or unit mutations, refuse `Set.Daemon && ManagedByActivator`; checking only Hibernate would permit the false-hibernate resident to install a second service. For `ManagedByActivator && RequiresResidentProcess`, also refuse any nonempty grantedPortTokens before any write or unit action. Converge currently has NO Hibernate-keyed bypass for granted sockets; this refusal is new. Preserve the existing exec dispatch bypasses at adopt.go:772, adopt.go:1207 and agent.go:2640 for all activator-managed routes, and carry the new refusals through rollback snapshots and all plan constructors. Unsupported combinations refuse before capability writes. A signed manifest cannot override this execution-class restriction.
+Carry `ManagedByActivator` and `RequiresResidentProcess` as explicit validated fields in `caps.Plan` from capabilityPlan and every other plan constructor. Before filesystem or unit mutations, refuse `Set.Daemon && ManagedByActivator`; the true-Hibernate encoding keeps the existing Daemon refusal applicable, and a future lifecycle consumer must preserve it. For `ManagedByActivator && RequiresResidentProcess`, also refuse any nonempty grantedPortTokens before any write or unit action. Converge currently has NO Hibernate-keyed bypass for granted sockets; this refusal is new. Preserve the existing exec dispatch bypasses at adopt.go:772, adopt.go:1207 and agent.go:2640 for all activator-managed routes, and carry the new refusals through rollback snapshots and all plan constructors. Unsupported combinations refuse before capability writes. A signed manifest cannot override this execution-class restriction.
The selected-release compatibility checker uses the proposed checked maintenance launch adapter, with command/context and executable verification, not the ordinary serving path. Child fd4 must be explicitly closed and checked before Home’s database descriptor baseline; worker-held executable proof remains retained. The adapter and corresponding Home open-contract amendment remain unimplemented review dependencies. This proposal adds neither a path-based fallback nor approval for an absent checker.
## Consumer inventory and tests
-`always-awake-consumers.json` records exact base source functions currently branching on Hibernate, plus the serializer/control boundaries that must carry the new shape. It is an implementation checklist; a passing search count does not establish coverage. Every listed family needs a regression or an explicit unchanged-behavior assertion. The earlier maintenance writer audit supplies record deletion, lifecycle claim, config and prefix-purge ordering; this amendment does not replace it.
+`always-awake-consumers.json` records exact base source functions currently branching on Hibernate, plus the serializer/control boundaries that must carry the new shape. It is an implementation checklist; a passing search count does not establish coverage. Use it to identify concrete changed contracts and their regressions; do not add a blanket set of tautological tests for unchanged branches. The earlier maintenance writer audit supplies record deletion, lifecycle claim, config and prefix-purge ordering; this amendment does not replace it.
| Family | Required result |
| --- | --- |
@@ -196,13 +206,13 @@ Acceptance requires these independent proofs:
2. Kill the resident app, adopt a new release, and issue an ordinary restart without traffic. Each recovers proactively with correct health/version. Repeated failures back off; requests and serial nudges cannot create a storm or bypass the ceiling. Failed cleanup blocks replacement.
3. Concurrent request/due/reconcile/owner change/lease loss yields one lease and writer. Stale desired results cannot publish a process after retirement or changed owner. Incomplete registry/store discovery does not fabricate retirement or authorize fresh startup.
4. The exact preflight-pinned active route account/app/host/creation identities and DB/WAL identity and semantic contents, Home principal IDs, directory generation, issuer keys, config/DEK/pepper sources and lease/replica prefix remain unchanged across activation, app restart and agent restart. Missing local DB restores only a validated existing replica; missing/corrupt replica never reaches Seed.
-5. Actual root replication configuration excludes both hibernating and resident exec DBs while preserving genuine unit coverage. Mutations using only `Hibernate`, clearing replication on projection, or making all false-hibernate sockets managed must fail.
-6. Policy schema/read/write tests cover every classified parser/writer family, including retry, delayed confirmation, raw list/purge, Upsert INSERT/CONFLICT rung preservation, replicationRail/carlos add, and former SetHibernate projection. Mutate each guard separately. A v1 row with stripped authoritative record must remain denied and root-excluded through repeated reconciliation and Edge.Reload, with checked cleanup and no external proxy handoff. Conflicting mode/owner/placement operations refuse before earlier side effects.
+5. Actual root replication configuration excludes both hibernating and resident exec DBs while preserving genuine unit coverage. Mutations clearing replication on projection or making legacy false-Hibernate sockets managed must fail. Ownership checks that correctly use true Hibernate remain valid; idle-sleep checks must additionally respect a validated resident policy.
+6. Policy schema/read/write tests cover every classified parser/writer family, including retry, delayed confirmation, raw list/purge, Upsert INSERT/CONFLICT rung preservation and the future lifecycle projection. Mutate each guard separately. A v1 row with stripped authoritative record must remain denied and root-excluded through repeated reconciliation and Edge.Reload, with checked cleanup and no external proxy handoff. Conflicting mode/owner/placement operations refuse before earlier side effects.
7. Fence versus queued/in-flight resident startup, scheduled token preparation, stale Route, sidecar start and adoption leaves no unauthorized child or environment mutation. Actual retained handles are stopped; absence alone is not proof. Maintenance state cannot be retried as a crash.
8. Restart the real canary agent supervisor with a descendant holding a pipe. Prove no old process tree survives to become a second writer. Hold a wake inside Start while Shutdown runs; return a live process afterward and prove joined checked cleanup before lease closure, with no awake publication. Check deadline expiry retains uncertainty. Existing unit/cgroup deployment capability and checked empty-tree evidence remain required.
9. Exercise maintenance on a synthetic always-awake instance only after the full maintenance gate: checked stop, no resident reawakening, private operation, checkpoint/restore receipt, explicit resume with the selected-release non-serving checker, then sustained residency. Omitted, failed, stale, wrong-release or wrong-DB checker evidence refuses before serving even when policy receipt and HTTP health would pass. Failure, crash and ambiguous publication remain fenced.
10. Aliases and path routing resolve the primary without duplicate startup. Credential and directory Home roles keep distinct state. Actual status reports process health/version and desired policy rather than treating socket presence as residency.
-11. Exact mixed-version/writer negative controls prove activation refuses when any required consumer or enforced floor is missing. An old-binary rollback cannot silently reenable hibernation, release ownership or introduce root replication.
+11. Exact mixed-version/writer negative controls prove activation refuses when any required consumer or enforced floor is missing. An old-binary rollback is refused by the enforced floor; true Hibernate alone prevents only ownership release and root replication, not idle sleep or lifecycle stripping.
12. Crash at every fixed-action boundary: claim, guard, projection, stop, executing, policy CAS, receipt, registry transaction, succeeded, resume guard and resume projection. Prove the recovery table, no automatic policy replay/start, exact old/target comparison, no-field-loss mutations, distinct recovered receipt, fresh owner recovery/resume and refusal with uncertain tree/lease. Include the paused previous maintenance executor counterexample even when the Home process tree is empty and a new DB lease is obtainable; refusal must precede all repair/publish/start.
13. A deployed synthetic canary, not localhost alone, proves cold startup, idle, crash, adoption and restored cold standby. Record observed recovery times and a written restore RTO. All native/race/cross/CI gates and independent Fable review pass before real Home policy changes.
@@ -219,6 +229,6 @@ The choice of an explicit always-awake exec lifecycle and preservation requireme
- A supported app-specific non-serving compatibility checker in each selected Home serving release. If none exists, implementing and reviewing it is a consumer dependency; current Home operator preparation, fixed-action success, recovered-target evidence and HTTP health are not substitutes.
- Exact Home primary/alias/role/config/DB ownership inventory from supported preflight. Supplied effective route receipts do not establish those facts.
-Implementation must also test Daemon with Hibernate=false and ManagedByActivator=true: refuse before unit/file writes, including rollback/adoption paths. Repeat with Daemon=false but a granted port and assert no socket/service write or start; preflight must also refuse unknown grants.
+Implementation must also test Daemon with Hibernate=true and a validated resident policy: refuse before unit/file writes, including rollback/adoption paths. Repeat with Daemon=false but a granted port and assert no socket/service write or start; preflight must also refuse unknown grants.
No Home policy change, unit migration, maintenance operation, signing contract or runtime-floor declaration is approved by committing this design.
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index b85fe8b8e..811178fb6 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -182,8 +182,9 @@ type instanceListRecord struct {
// HiddenGate shadows Record.Gate for encoding/json (a shallower field of
// the same name wins), so the bcrypt hash never leaves the console; it
// is always "" and omitted. Gated is what a member may know.
- HiddenGate string `json:"gate,omitempty"`
- Gated bool `json:"gated"`
+ HiddenLifecycle string `json:"lifecycle,omitempty"`
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
}
// instanceHealth computes each record host's health word the same way the
@@ -502,8 +503,9 @@ type instanceGetRecord struct {
// HiddenGate shadows Record.Gate for encoding/json (a shallower field of
// the same name wins), so the bcrypt hash never leaves the console; it
// is always "" and omitted. Gated is what a member may know.
- HiddenGate string `json:"gate,omitempty"`
- Gated bool `json:"gated"`
+ HiddenLifecycle string `json:"lifecycle,omitempty"`
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
}
// apiRecordView is a record as the write doors answer it: the whole record
@@ -512,8 +514,9 @@ type instanceGetRecord struct {
// place — not five — where "the hash never leaves" has to hold.
type apiRecordView struct {
provision.Record
- HiddenGate string `json:"gate,omitempty"`
- Gated bool `json:"gated"`
+ HiddenLifecycle string `json:"lifecycle,omitempty"`
+ HiddenGate string `json:"gate,omitempty"`
+ Gated bool `json:"gated"`
}
func recordView(rec provision.Record) apiRecordView {
diff --git a/internal/console/lifecycle_views_test.go b/internal/console/lifecycle_views_test.go
new file mode 100644
index 000000000..1183f7f67
--- /dev/null
+++ b/internal/console/lifecycle_views_test.go
@@ -0,0 +1,34 @@
+package console
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/provision"
+ "testing"
+)
+
+func TestLifecycleViewsOmitPolicy(t *testing.T) {
+ rec := provision.Record{Host: "a.example", Gate: "private hash", Lifecycle: provision.Lifecycle(`"always-awake-exec-v1"`)}
+ views := []any{instanceListRecord{Record: rec, StagedVersion: "version", Gated: true}, instanceGetRecord{Record: rec, Gated: true}, recordView(rec)}
+ for _, view := range views {
+ b, err := json.Marshal(view)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err = json.Unmarshal(b, &fields); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"gate", "lifecycle"} {
+ if _, ok := fields[key]; ok {
+ t.Fatal("private field escaped", key, string(b))
+ }
+ }
+ if string(fields["gated"]) != "true" || string(fields["host"]) != `"a.example"` {
+ t.Fatal("wrapper lost fields")
+ }
+ }
+ var list instanceListRecord
+ if err := json.Unmarshal([]byte(`{"host":"a.example","staged_version":"v","gated":true}`), &list); err != nil || list.StagedVersion != "v" || !list.Gated || list.Host != "a.example" {
+ t.Fatal("wrapper decode", err)
+ }
+}
diff --git a/internal/provision/lifecycle.go b/internal/provision/lifecycle.go
new file mode 100644
index 000000000..8a56bce81
--- /dev/null
+++ b/internal/provision/lifecycle.go
@@ -0,0 +1,49 @@
+package provision
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+)
+
+const LifecycleAlwaysAwakeExecV1 = "always-awake-exec-v1"
+
+var ErrLifecycle = errors.New("provision: invalid lifecycle")
+
+// Lifecycle preserves present invalid input so an ignored decode error cannot
+// erase it on a later write. An empty slice means the field was absent.
+type Lifecycle []byte
+
+func (p *Lifecycle) UnmarshalJSON(raw []byte) error {
+ *p = append((*p)[:0], raw...)
+ _, err := p.Value()
+ return err
+}
+func (p Lifecycle) MarshalJSON() ([]byte, error) {
+ if _, err := p.Value(); err != nil {
+ return nil, err
+ }
+ if len(p) == 0 {
+ return []byte(`""`), nil
+ }
+ return append([]byte(nil), p...), nil
+}
+func (p Lifecycle) Value() (string, error) {
+ if len(p) == 0 {
+ return "", nil
+ }
+ raw := bytes.TrimSpace(p)
+ var value string
+ if len(raw) == 0 || raw[0] != '"' || json.Unmarshal(raw, &value) != nil || !ValidLifecycle(value) {
+ return "", ErrLifecycle
+ }
+ return value, nil
+}
+func ValidLifecycle(value string) bool { return value == "" || value == LifecycleAlwaysAwakeExecV1 }
+func lifecycleConsistent(rec Record) error {
+ value, err := rec.Lifecycle.Value()
+ if err != nil || (value != "" && (rec.Hibernate == nil || !*rec.Hibernate)) {
+ return ErrLifecycle
+ }
+ return nil
+}
diff --git a/internal/provision/lifecycle_test.go b/internal/provision/lifecycle_test.go
new file mode 100644
index 000000000..2ba809588
--- /dev/null
+++ b/internal/provision/lifecycle_test.go
@@ -0,0 +1,247 @@
+package provision
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func lifecycleV1() Lifecycle { return Lifecycle(`"always-awake-exec-v1"`) }
+func lifecycleTrue() *bool { v := true; return &v }
+
+func TestLifecycleDecode(t *testing.T) {
+ for _, raw := range []string{`{}`, `{"lifecycle":""}`, `{"lifecycle":"always-awake-exec-v1","hibernate":true}`} {
+ r, err := DecodeRecord([]byte(raw))
+ if err != nil {
+ t.Fatal(raw, err)
+ }
+ b, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, err := DecodeRecord(b)
+ if err != nil || !bytes.Equal(r.Lifecycle, again.Lifecycle) {
+ t.Fatal("lost presence", err)
+ }
+ if len(r.Lifecycle) != 0 {
+ var encoded map[string]json.RawMessage
+ _ = json.Unmarshal(b, &encoded)
+ if !bytes.Equal(encoded["lifecycle"], r.Lifecycle) {
+ t.Fatal("changed lifecycle bytes")
+ }
+ }
+ }
+ for _, raw := range []string{`{"lifecycle":null}`, `{"lifecycle":1}`, `{"lifecycle":{}}`, `{"lifecycle":"future"}`, `{"Lifecycle":""}`, `{"lifecycle":"","lifecycle":""}`, `{"lifecycle":"always-awake-exec-v1"}`, `{"lifecycle":"always-awake-exec-v1","hibernate":null}`, `{"lifecycle":"always-awake-exec-v1","hibernate":false}`} {
+ r, err := DecodeRecord([]byte(raw))
+ if !errors.Is(err, ErrLifecycle) {
+ t.Fatal(raw, err)
+ }
+ if _, err = json.Marshal(r); err == nil {
+ t.Fatal("invalid record lost poison", raw)
+ }
+ }
+ for _, raw := range []string{`{"Hibernate":true}`, `{"hibernate":false,"hibernate":true}`, `{"hibernate":null}`, `{"future_record_field":42}`} {
+ if _, err := DecodeRecord([]byte(raw)); err != nil {
+ t.Fatal("legacy refused", raw, err)
+ }
+ if bytes.Contains([]byte(raw), []byte("ibernate")) && raw != `{"hibernate":null}` {
+ for _, scoped := range []string{`{"lifecycle":"",` + raw[1:], raw[:len(raw)-1] + `,"lifecycle":""}`} {
+ if _, err := DecodeRecord([]byte(scoped)); !errors.Is(err, ErrLifecycle) {
+ t.Fatal("scoped ambiguity accepted", scoped, err)
+ }
+ }
+ }
+ }
+}
+
+func TestLifecycleIgnoredError(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ before, tag, _ := s.Get(ctx, key)
+ for _, value := range []string{`null`, `1`, `{}`, `"future"`} {
+ candidate := rec
+ if json.Unmarshal([]byte(`{"lifecycle":`+value+`}`), &candidate) == nil {
+ t.Fatal("accepted invalid")
+ }
+ if _, err := json.Marshal(candidate); err == nil {
+ t.Fatal("ignored error erased poison")
+ }
+ if err := UpdateRecord(ctx, &projectionRaceStore{Store: s, beforePut: func() { t.Fatal("invalid update called Put") }}, candidate, tag); err != ErrMaintenanceProjection {
+ t.Fatal(err)
+ }
+ after, next, _ := s.Get(ctx, key)
+ if !bytes.Equal(before, after) || tag != next {
+ t.Fatal("refused write changed bytes")
+ }
+ }
+}
+
+func TestLifecycleWriters(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ rec.Hibernate = lifecycleTrue()
+ rec.Lifecycle = lifecycleV1()
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ for _, value := range []Lifecycle{lifecycleV1(), Lifecycle(`""`), Lifecycle(`null`)} {
+ rec.Lifecycle = value
+ if err := CreateRecord(ctx, s, rec); err != ErrMaintenanceProjection {
+ t.Fatal("create accepted", err)
+ }
+ if _, _, err := s.Get(ctx, key); err == nil {
+ t.Fatal("create wrote")
+ }
+ }
+ rec.Lifecycle = lifecycleV1()
+ b, _ := json.Marshal(rec)
+ if _, err := s.Put(ctx, key, b, ""); err != nil {
+ t.Fatal(err)
+ }
+ check := func() {
+ t.Helper()
+ got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil || !bytes.Equal(got.Lifecycle, lifecycleV1()) || got.Hibernate == nil || !*got.Hibernate {
+ t.Fatal("policy lost", err)
+ }
+ }
+ if err := SetRecordRegion(ctx, s, rec.Account, rec.App, rec.Host, "ie"); err != nil {
+ t.Fatal(err)
+ }
+ check()
+ for _, mut := range []func(*Record) error{MoveRequest("uk", time.Now()), MovePromoteReady(time.Now()), MoveMarkCutting(), MoveComplete()} {
+ if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil {
+ t.Fatal(err)
+ }
+ check()
+ }
+ got, tag, _ := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
+ got.Channel = "new"
+ if err := UpdateRecord(ctx, s, *got, tag); err != nil {
+ t.Fatal(err)
+ }
+ check()
+ for _, mut := range []func(*Record){func(r *Record) { r.Lifecycle = nil }, func(r *Record) { r.Lifecycle = Lifecycle(`""`) }, func(r *Record) { r.Lifecycle = Lifecycle(`"future"`) }, func(r *Record) { r.Hibernate = nil }, func(r *Record) { v := false; r.Hibernate = &v }, func(r *Record) { r.Lifecycle[1] = 'X' }} {
+ before, etag, _ := s.Get(ctx, key)
+ current, _, _ := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
+ mut(current)
+ if err := UpdateRecord(ctx, &projectionRaceStore{Store: s, beforePut: func() { t.Fatal("refused update called Put") }}, *current, etag); err != ErrMaintenanceProjection {
+ t.Fatal("whole mutation accepted", err)
+ }
+ if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { mut(r); return nil }); err != ErrMaintenanceProjection {
+ t.Fatal("callback mutation accepted", err)
+ }
+ after, next, _ := s.Get(ctx, key)
+ if !bytes.Equal(before, after) || etag != next {
+ t.Fatal("mutation wrote")
+ }
+ }
+ // In-place mutation to a different valid value also requires an independent snapshot.
+ if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error {
+ for i := range r.Lifecycle {
+ r.Lifecycle[i] = ' '
+ }
+ copy(r.Lifecycle, []byte(`""`))
+ return nil
+ }); err != ErrMaintenanceProjection {
+ t.Fatal("in-place clear accepted", err)
+ }
+}
+
+func TestLifecycleInstallAndCAS(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ rec.Hibernate = lifecycleTrue()
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ before, tag, _ := s.Get(ctx, key)
+ r := rec
+ r.Lifecycle = lifecycleV1()
+ if err := UpdateRecord(ctx, s, r, tag); err != ErrMaintenanceProjection {
+ t.Fatal("install accepted", err)
+ }
+ after, next, _ := s.Get(ctx, key)
+ if !bytes.Equal(before, after) || tag != next {
+ t.Fatal("install wrote")
+ }
+ wrapped := &projectionRaceStore{Store: s, beforePut: func() { b, _ := json.Marshal(r); putReplace(t, s, key, b) }}
+ if err := SetRecordRegion(ctx, wrapped, rec.Account, rec.App, rec.Host, "uk"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil || !bytes.Equal(got.Lifecycle, lifecycleV1()) || got.Region != "uk" {
+ t.Fatal("retry lost policy", err)
+ }
+}
+
+func TestLifecycleReconcile(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ rec.Hibernate = lifecycleTrue()
+ legacy := routeFor(rec, "/data", "seed")
+ rec.Lifecycle = lifecycleV1()
+ if got := routeFor(rec, "/data", "seed"); !reflect.DeepEqual(got, legacy) {
+ t.Fatal("runtime projection changed")
+ }
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ b, _ := json.Marshal(rec)
+ if _, err := s.Put(ctx, key, b, ""); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ before := mustGet(t, reg, rec.Host)
+ b = bytes.Replace(b, []byte(`"always-awake-exec-v1"`), []byte(`"future"`), 1)
+ putReplace(t, s, key, b)
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ after := mustGet(t, reg, rec.Host)
+ if !reflect.DeepEqual(before, after) {
+ t.Fatal("malformed policy changed owned route")
+ }
+ if rows, err := ListAllRecordsComplete(ctx, s); err == nil || rows != nil {
+ t.Fatal("incomplete steering list accepted")
+ }
+}
+
+func TestLifecyclePresenceProtected(t *testing.T) {
+ for _, present := range []bool{false, true} {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ if present {
+ rec.Lifecycle = Lifecycle(`""`)
+ }
+ key := RecordKey(rec.Account, rec.App, rec.Host)
+ b, _ := json.Marshal(rec)
+ if _, err := s.Put(ctx, key, b, ""); err != nil {
+ t.Fatal(err)
+ }
+ current, tag, err := GetRecordETag(ctx, s, rec.Account, rec.App, rec.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if present {
+ current.Lifecycle = nil
+ } else {
+ current.Lifecycle = Lifecycle(`""`)
+ }
+ if err := UpdateRecord(ctx, &projectionRaceStore{Store: s, beforePut: func() { t.Fatal("presence change called Put") }}, *current, tag); err != ErrMaintenanceProjection {
+ t.Fatal("presence changed", err)
+ }
+ after, next, _ := s.Get(ctx, key)
+ if !bytes.Equal(b, after) || tag != next {
+ t.Fatal("presence changed storage")
+ }
+ }
+}
diff --git a/internal/provision/maintenance.go b/internal/provision/maintenance.go
index dc8b6aa90..41af0fe8e 100644
--- a/internal/provision/maintenance.go
+++ b/internal/provision/maintenance.go
@@ -43,6 +43,9 @@ func DecodeRecord(raw []byte) (Record, error) {
return invalid, ErrMaintenanceProjection
}
seen := false
+ lifecycleSeen := false
+ hibernateCount := 0
+ hibernateAlias := false
for dec.More() {
key, e := dec.Token()
if e != nil {
@@ -58,6 +61,16 @@ func DecodeRecord(raw []byte) (Record, error) {
}
seen = true
}
+ if strings.EqualFold(name, "lifecycle") {
+ if name != "lifecycle" || lifecycleSeen {
+ return invalid, ErrLifecycle
+ }
+ lifecycleSeen = true
+ }
+ if strings.EqualFold(name, "hibernate") {
+ hibernateCount++
+ hibernateAlias = hibernateAlias || name != "hibernate"
+ }
var value json.RawMessage
if dec.Decode(&value) != nil {
return invalid, ErrMaintenanceProjection
@@ -69,16 +82,33 @@ func DecodeRecord(raw []byte) (Record, error) {
if _, e = dec.Token(); e != io.EOF {
return invalid, ErrMaintenanceProjection
}
+ if lifecycleSeen && (hibernateCount > 1 || hibernateAlias) {
+ return invalid, ErrLifecycle
+ }
var rec Record
- if json.Unmarshal(raw, &rec) != nil {
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ if errors.Is(err, ErrLifecycle) {
+ return invalid, ErrLifecycle
+ }
return invalid, ErrMaintenanceProjection
}
+ if lifecycleConsistent(rec) != nil {
+ return invalid, ErrLifecycle
+ }
return rec, nil
}
// Equality preserves the projection, not permission to change a fenced record.
// Durable guard and lifecycle checks belong to the maintenance integration.
func protectedFieldsEqual(before, after Record) bool {
+ if (len(before.Lifecycle) == 0) != (len(after.Lifecycle) == 0) {
+ return false
+ }
+ av, ae := before.Lifecycle.Value()
+ bv, be := after.Lifecycle.Value()
+ if ae != nil || be != nil || av != bv {
+ return false
+ }
if before.Account != after.Account || before.App != after.App || before.Host != after.Host || !before.CreatedAt.Equal(after.CreatedAt) {
return false
}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 7c68ed826..8a680cd42 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -36,6 +36,7 @@ import (
// Account vs the key's segments); GetRecord and ListRecords enforce that, a
// record that lies about identity is never guessed at (design §3.2).
type Record struct {
+ Lifecycle Lifecycle `json:"lifecycle,omitempty"`
// Maintenance is preserved by ordinary writers; only a future guarded
// maintenance transition may install, replace, or clear it.
Maintenance MaintenanceProjection `json:"maintenance,omitempty"`
@@ -649,7 +650,7 @@ func PutManifestIfMatch(ctx context.Context, s release.Store, acct, app string,
// against a second signup for the same host comes back wrapping
// release.ErrCASConflict, never a silent overwrite of the winner's record.
func CreateRecord(ctx context.Context, s release.Store, rec Record) error {
- if len(rec.Maintenance) != 0 {
+ if len(rec.Maintenance) != 0 || len(rec.Lifecycle) != 0 {
return ErrMaintenanceProjection
}
body, err := json.Marshal(rec)
@@ -709,10 +710,11 @@ func updateRecord(ctx context.Context, s release.Store, acct, app, host string,
}
before := rec
before.Maintenance = append(MaintenanceProjection(nil), rec.Maintenance...)
+ before.Lifecycle = append(Lifecycle(nil), rec.Lifecycle...)
if err := mut(&rec); err != nil {
return nil, err
}
- if !protectedFieldsEqual(before, rec) {
+ if !protectedFieldsEqual(before, rec) || lifecycleConsistent(rec) != nil {
return nil, ErrMaintenanceProjection
}
body, err := json.Marshal(rec)
@@ -923,7 +925,7 @@ func UpdateRecord(ctx context.Context, s release.Store, rec Record, etag string)
if currentETag != etag {
return release.ErrCASConflict
}
- if !protectedFieldsEqual(*before, rec) {
+ if !protectedFieldsEqual(*before, rec) || lifecycleConsistent(rec) != nil {
return ErrMaintenanceProjection
}
body, err := json.Marshal(rec)