kind: FeatureFlag
What it is
kind: FeatureFlag declares a runtime toggle that Crewship code
checks at request time. A flag has two layers and the manifest can
write to both in a single document:
-
The instance-global definition — name (
key), description, the defaultenabledstate, and an optionaldefault_percentagefor gradual-rollout flags. This row lives in thefeature_flagstable and is shared by every workspace on the instance. Only ADMIN may create, update, or delete it. -
The workspace override — an optional per-workspace
force-enable / force-disable that takes precedence over the
instance default. The override row lives in
feature_flag_overrideskeyed by(flag_id, workspace_id). OWNER and ADMIN of the target workspace may write it.
Why two layers in one document
Most operational uses look like: “ship the flag definition with the codebase, then let individual workspaces opt-in (or stay opted-out) without round-tripping through the admin console.” Splitting the two concerns into separate kinds would force two YAML files and two PRs for every flag rollout. Keeping them in one document with an optionalworkspace_override keeps the surface tight and lets
crewship apply reconcile both layers in one phase.
Instance default vs workspace override — concretely
The
workspace_override field is pointer-typed in the Go
struct (*bool). That is deliberate: an absent field means
“inherit”, which is structurally different from “force OFF
(false)”. Re-applying a manifest that omits the field will
DELETE any stale override the workspace happened to have — that’s
how operators clear an override and return to the instance default.
YAML schema
Field reference
Examples
Minimal — definition only
Defines an instance-global flag with no workspace-specific override. Every workspace inheritsdefault_enabled.
Realistic — definition + workspace opt-in
Same flag, but the workspace this manifest applies to wants the feature ON regardless of the instance default. Two PlanItems will be emitted on first apply: POST the definition, then PUT the override.Force-disable for a specific workspace
The instance default istrue, but this workspace explicitly opts
out — useful for a customer who isn’t ready for the change.
Clear an existing override
Drop theworkspace_override: line entirely and re-apply. The plan
emits one ActionDelete against
DELETE /api/v1/feature-flags/<key>/override. The flag definition
itself is untouched.
CLI reference
crewship workspace use / CREWSHIP_WORKSPACE); there is no --workspace flag for targeting an arbitrary other workspace from the CLI. To cross-target, switch workspace context first.
For full CRUD (creating new flag definitions, deleting flags) use
crewship apply --file flag.yaml. The admin CLI intentionally
does NOT expose feature-flag create / delete — flag definitions
are a versioned codebase concern and should land via a reviewed
manifest, not an ad-hoc shell command.
REST endpoint mapping
DB columns:
feature_flags(key, description, enabled, percentage) +
feature_flag_overrides(flag_id, workspace_id, enabled). See
internal/database/migrate_consts_v01_init.go for the schema.
Validation rules
metadata.slugis required (used as the server-sidekey).spec.default_percentageMUST be in the closed range[0, 100]. Out-of-range values fail validation at parse time, before any REST call.spec.workspace_override, when present, must be a YAML boolean (true/false). Pointer typing means “field omitted” is a third valid state and not a validation failure.- No cross-kind FK references — FeatureFlag is a leaf kind in the
dependency graph. The
WorkspaceContextargument toValidateis accepted for signature uniformity but ignored.
Apply behavior
Default mode (Upsert)
Plan() emits one PlanItem per drifted concern, with a maximum of two items per flag:- Flag definition missing remotely →
ActionCreate(POST). - Flag definition present but
descriptionordefault_enableddiffer →ActionUpdate(PATCH). - Flag definition present and identical → no item.
workspace_overrideset in YAML and differs from remote (or remote has no override) →ActionUpdate(PUT override).workspace_overrideabsent in YAML and remote has an override row →ActionDelete(DELETE override).workspace_overrideabsent both sides → no item.
default_percentage is deliberately excluded from the update
diff. Percentage rollouts are typically tuned at runtime by ops
(“bump to 25%”); re-applying a manifest that hard-codes the
original 0 would fight the live system on every CI run. The
field is still honored on POST (for first-time bootstrap) and
round-trips via export, but crewship apply won’t reset it once
the row exists.
ApplyStrict
Fails with a clear error if the flag definition already exists. Use this in fresh-instance bootstrap pipelines where any pre-existing flag is a sign of a previous half-baked apply.ApplyReplace
EmitsActionDelete for the flag definition first, then
ActionCreate to recreate it from the manifest. Workspace
override rows are cascaded-deleted by the foreign key on the
feature_flags table, then recreated by the same Plan logic if
the manifest declares an override. This mode is destructive — use
--yes to bypass the interactive confirmation only after the dry
run looks right.
Round-trip via export
crewship export workspace includes one kind: FeatureFlag
document per flag definition the user can read. The
workspace_override field is emitted ONLY when the server reports
an override row for the current workspace (the JSON pointer
workspace_override field is non-nil on the response). Pointer
semantics carry through to the YAML: an absent field means
“inherit the default.”
Round-trip is lossless for description, default_enabled,
default_percentage, and workspace_override. The
feature_flags.id column (CUID) is regenerated on a fresh apply
and is not preserved — references between kinds are always by
slug, never by id.
See also
- InstanceSetting — the other admin-only kind in the manifest; same dual-layer pattern but for configuration key/value pairs instead of feature toggles.
- SPEC-2 §9 (
.claude/context/specs/SPEC-2-manifest-complete.md) — the authoritative implementation contract.