293 lines
12 KiB
Text
293 lines
12 KiB
Text
# Positioning defaults — make_* helpers that apply cross-field contracts
|
||
# on top of the raw schema in `../schemas/positioning.ncl`.
|
||
#
|
||
# Same pattern as adr-defaults.ncl: schema declares record shapes, defaults
|
||
# applies the cross-field invariants (multi-field reads) on the merged record.
|
||
|
||
let s = import "../schemas/positioning.ncl" in
|
||
|
||
# value-prop: audience_ids must not be empty — a claim without a target is
|
||
# positioning into the void.
|
||
let _non_empty_audience_ids = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.array.length value.audience_ids == 0 then
|
||
'Error {
|
||
message = "value-prop '%{value.id}': audience_ids must not be empty — a claim without a target audience is positioning into the void"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# value-prop: status='Live requires non-empty evidence_adrs — a Live claim
|
||
# that cites no architectural decision as its backing is marketing without
|
||
# substance. Drafts may be unbacked (the ADRs may not exist yet).
|
||
let _live_requires_evidence_adrs = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if value.status == 'Live && std.array.length value.evidence_adrs == 0 then
|
||
'Error {
|
||
message = "value-prop '%{value.id}': status='Live but evidence_adrs is empty — Live claims must cite at least one ADR as architectural backing"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# value-prop / campaign: status='Retired requires retired_at (audit trail).
|
||
let _retired_has_date = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if value.status == 'Retired && !(std.record.has_field "retired_at" value) then
|
||
'Error {
|
||
message = "'%{value.id}': status='Retired requires retired_at (ISO date) for audit trail"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# value-prop: if superseded_by is set, status must be 'Retired (forbidden to
|
||
# claim supersession while remaining Live).
|
||
let _superseded_implies_retired = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.record.has_field "superseded_by" value && value.status != 'Retired then
|
||
'Error {
|
||
message = "value-prop '%{value.id}': superseded_by is set but status is not 'Retired — supersession implies retirement"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# launch_check: per-tag well-formedness. Mirrors EachCheckWellFormed in
|
||
# adr-constraints.ncl. Kept in sync manually until ADR-035's lift-out lands.
|
||
let _each_launch_check_well_formed = std.contract.custom (
|
||
fun label =>
|
||
fun checks =>
|
||
let validate = fun c =>
|
||
let tag = c.tag in
|
||
let needs = fun field => !(std.record.has_field field c) in
|
||
if tag == 'Grep then
|
||
if needs "pattern" || needs "paths" || needs "must_be_empty" then
|
||
"Grep check requires 'pattern', 'paths', 'must_be_empty'"
|
||
else ""
|
||
else if tag == 'NuCmd then
|
||
if needs "cmd" || needs "expect_exit" then
|
||
"NuCmd check requires 'cmd' and 'expect_exit'"
|
||
else ""
|
||
else if tag == 'ApiCall then
|
||
if needs "endpoint" || needs "json_path" || needs "expected" then
|
||
"ApiCall check requires 'endpoint', 'json_path', 'expected'"
|
||
else ""
|
||
else if tag == 'FileExists then
|
||
if needs "path" || needs "present" then
|
||
"FileExists check requires 'path' and 'present'"
|
||
else ""
|
||
else
|
||
"unknown launch_check tag '%{std.to_str tag}'"
|
||
in
|
||
let first_err =
|
||
std.array.fold_left
|
||
(fun acc c => if acc != "" then acc else validate c)
|
||
""
|
||
checks
|
||
in
|
||
if first_err == "" then 'Ok checks
|
||
else 'Error { message = first_err }
|
||
) in
|
||
|
||
# value-prop: status='Live requires >=1 differentiator — the QUÉ×QUIÉN seam.
|
||
# A Live claim aimed at an audience with no differential value behind it is
|
||
# positioning without substance (coherence rule 3, local half).
|
||
let _live_requires_differentiator = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if value.status == 'Live && std.array.length value.differentiator_ids == 0 then
|
||
'Error {
|
||
message = "value-prop '%{value.id}': status='Live but differentiator_ids is empty — a Live claim must bind to >=1 differentiator (the QUÉ axis)"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# differentiator: must be anchored — >=1 linked_node AND >=1 evidence_adr.
|
||
# An unanchored differentiator is a feature claim, not a value difference.
|
||
let _diff_requires_anchor = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.array.length value.linked_nodes == 0 || std.array.length value.evidence_adrs == 0 then
|
||
'Error {
|
||
message = "differentiator '%{value.id}': must cite >=1 linked_node and >=1 evidence_adr — an unanchored differentiator is a feature, not a value difference"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# differentiator: `vs` must name >=1 competitor category it beats.
|
||
let _diff_requires_vs = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.array.length value.vs == 0 then
|
||
'Error {
|
||
message = "differentiator '%{value.id}': vs must name >=1 competitor category that does NOT do this"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# difusion mechanism: >=1 channel AND >=1 thing it difunde (value-prop or
|
||
# differentiator) — difusión with no channel or no payload is empty.
|
||
let _mechanism_non_empty = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.array.length value.channels == 0 then
|
||
'Error {
|
||
message = "mechanism '%{value.id}': channels must not be empty — difusión needs a channel"
|
||
}
|
||
else if std.array.length value.value_prop_ids == 0 && std.array.length value.differentiator_ids == 0 then
|
||
'Error {
|
||
message = "mechanism '%{value.id}': must difundir >=1 value-prop or differentiator"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# proof: converges must carry >=1 id on each axis — the triple seam. A proof
|
||
# is the convergence of all three or it is not a proof.
|
||
let _proof_converges_all = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
let c = value.converges in
|
||
if std.array.length c.differentiator_ids == 0
|
||
|| std.array.length c.audience_ids == 0
|
||
|| std.array.length c.mechanism_ids == 0 then
|
||
'Error {
|
||
message = "proof '%{value.id}': converges must reference >=1 differentiator, >=1 audience and >=1 mechanism — a proof is the triple convergence or it is not one"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# viability-path: converges must carry >=1 audience, >=1 mechanism and >=1
|
||
# value-prop — a sustaining flow that does not draw from an audience reached by a
|
||
# difusión mechanism monetizing a claim is not a path, it is a wish.
|
||
let _viability_converges_all = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
let c = value.converges in
|
||
if std.array.length c.audience_ids == 0
|
||
|| std.array.length c.mechanism_ids == 0
|
||
|| std.array.length c.value_prop_ids == 0 then
|
||
'Error {
|
||
message = "viability-path '%{value.id}': converges must reference >=1 audience, >=1 mechanism and >=1 value-prop — a sustaining flow is the convergence of PARA QUIÉN × CÓMO × (QUÉ×QUIÉN) or it is not a path"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# viability-path: must be anchored to >=1 ontology node — a viability path is
|
||
# meant to engage the openness-vs-sustainability tension explicitly, not float
|
||
# free of the substrate. Mirrors _diff_requires_anchor's linked_nodes half.
|
||
let _viability_anchored = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if std.array.length value.linked_nodes == 0 then
|
||
'Error {
|
||
message = "viability-path '%{value.id}': must cite >=1 linked_node — a viability path that engages no ontology node (typically openness-vs-sustainability) is reasoned from outside the graph"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# viability-path: a realized status (Validated|Live) requires realized_outcome —
|
||
# you cannot claim a flow sustains the project without a real, measured result.
|
||
# One-directional gate (realized ⇒ outcome present), NOT a Hard biconditional on
|
||
# the Spiral question: Draft paths are hypotheses and may carry no outcome.
|
||
let _realized_requires_outcome = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if (value.status == 'Validated || value.status == 'Live)
|
||
&& !(std.record.has_field "realized_outcome" value) then
|
||
'Error {
|
||
message = "viability-path '%{value.id}': status='%{std.to_str value.status} but realized_outcome is absent — a realized path must carry the real, measured compensation outcome; Draft paths may omit it"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
# viability-path: a Live path requires >=1 evidence_adr — a flow declared live as
|
||
# architectural commitment must cite the decision that froze it. Drafts may be
|
||
# unbacked (the ADR may not exist yet). Mirrors _live_requires_evidence_adrs.
|
||
let _viability_live_requires_adr = std.contract.custom (
|
||
fun label =>
|
||
fun value =>
|
||
if value.status == 'Live && std.array.length value.evidence_adrs == 0 then
|
||
'Error {
|
||
message = "viability-path '%{value.id}': status='Live but evidence_adrs is empty — a Live viability path must cite >=1 ADR as architectural backing"
|
||
}
|
||
else
|
||
'Ok value
|
||
) in
|
||
|
||
{
|
||
make_audience = fun data => s.Audience & data,
|
||
|
||
make_value_prop = fun data =>
|
||
let r1 | _non_empty_audience_ids = s.ValueProp & data in
|
||
let r2 | _live_requires_evidence_adrs = r1 in
|
||
let r3 | _live_requires_differentiator = r2 in
|
||
let r4 | _retired_has_date = r3 in
|
||
let r5 | _superseded_implies_retired = r4 in
|
||
let _ | _each_launch_check_well_formed = r5.launch_criteria in
|
||
r5,
|
||
|
||
make_campaign = fun data =>
|
||
let r1 | _retired_has_date = s.Campaign & data in
|
||
let _ | _each_launch_check_well_formed = r1.launch_criteria in
|
||
r1,
|
||
|
||
make_differentiator = fun data =>
|
||
let r1 | _diff_requires_anchor = s.Differentiator & data in
|
||
let r2 | _diff_requires_vs = r1 in
|
||
let _ | _retired_has_date = r2 in
|
||
r2,
|
||
|
||
make_competitor = fun data => s.Competitor & data,
|
||
|
||
make_difusion_mechanism = fun data =>
|
||
let r1 | _mechanism_non_empty = s.DifusionMechanism & data in
|
||
let r2 | _retired_has_date = r1 in
|
||
let _ | _each_launch_check_well_formed = r2.launch_criteria in
|
||
r2,
|
||
|
||
make_proof = fun data =>
|
||
let r1 | _proof_converges_all = s.Proof & data in
|
||
let r2 | _retired_has_date = r1 in
|
||
let _ | _each_launch_check_well_formed = r2.launch_criteria in
|
||
r2,
|
||
|
||
make_viability_path = fun data =>
|
||
let r1 | _viability_converges_all = s.ViabilityPath & data in
|
||
let r2 | _viability_anchored = r1 in
|
||
let r3 | _realized_requires_outcome = r2 in
|
||
let r4 | _viability_live_requires_adr = r3 in
|
||
let r5 | _retired_has_date = r4 in
|
||
let _ | _each_launch_check_well_formed = r5.launch_criteria in
|
||
r5,
|
||
|
||
Audience = s.Audience,
|
||
ValueProp = s.ValueProp,
|
||
Campaign = s.Campaign,
|
||
Differentiator = s.Differentiator,
|
||
Competitor = s.Competitor,
|
||
DifusionMechanism = s.DifusionMechanism,
|
||
Proof = s.Proof,
|
||
ViabilityPath = s.ViabilityPath,
|
||
LaunchCheck = s.LaunchCheck,
|
||
LaunchCheckTag = s.LaunchCheckTag,
|
||
PositioningStatus = s.PositioningStatus,
|
||
AudienceStatus = s.AudienceStatus,
|
||
MechanismKind = s.MechanismKind,
|
||
ProofKind = s.ProofKind,
|
||
Directness = s.Directness,
|
||
FlowKind = s.FlowKind,
|
||
}
|