catalog

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: AGPL-3.0 Imports: 19 Imported by: 0

Documentation

Overview

Package catalog holds the product catalogue a self-service portal orders from: what may be ordered, how the parts of a product relate, and what a release proves before anything can be ordered against it.

It is the first of the three models in ADR-0312. Design-time state throughout — nothing here reaches the event log, the processor or recovery, and none of the six engine invariants applies to it. What does apply is I5: everything provable when a catalogue is published is proved there, so ordering never interprets a graph (see Publish).

Index

Constants

View Source
const OrderProcess = "atlas-auftrag-erfuellung"

OrderProcess is the orchestration every order goes through. It is named here rather than imported so this package does not depend on the order package for one string; the guard in fulfilmentreport_test.go holds the two together.

Variables

View Source
var Typefaces = map[string]string{
	"system":   `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`,
	"humanist": `"Segoe UI", Candara, Optima, "Trebuchet MS", sans-serif`,
	"serif":    `Georgia, Cambria, "Times New Roman", serif`,
	"mono":     `ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace`,
}

Typefaces are the font stacks the binary ships. Each names a fallback, because a stack of one is a stack that fails on the machine without that font.

Functions

func ApprovalProcessFor

func ApprovalProcessFor(it Item) string

ApprovalProcessFor names the process that decides this item, or "" when it needs none. Exported for the guard that holds the table above to the order package's own resolver, which is the only thing keeping the copy honest.

func LanguageListProblem

func LanguageListProblem(langs []string) string

LanguageListProblem reports what is wrong with a catalogue's declared language list, or the empty string when nothing is.

It names the offending tag rather than the list. A refusal reading "invalid languages" against a list of four leaves somebody guessing which one, and the guess is what put the bad tag there in the first place.

A duplicate is refused for a reason of its own and not for tidiness: every per-language box in the Console is drawn from this list, so a repeated tag draws two boxes writing one key, where the second silently wins and the first looks like it was ignored.

func Publish

func Publish(in Input) (Release, []Problem)

Publish validates a catalogue set and computes its release. A non-empty problem list means nothing was published; the returned Release is then zero.

func ValidLanguageTag

func ValidLanguageTag(s string) bool

ValidLanguageTag reports whether s is a language tag this catalogue model will store: a BCP 47 tag, narrowed to the shape a maintainer types (ADR-0413).

Why this exists at all

Because a language tag is an opaque map key everywhere else in this tree. Texts are `map[string]string`, the Console draws one box per declared language, and the portal looks up `texts[locale]` — none of those can tell a tag from a sentence, and none of them fails loudly when the key is nonsense. They simply find nothing and fall back, which looks like a product that was never translated.

That is not hypothetical. A live catalogue was saved with the single language `de; en`, because the list is read comma-separated and somebody typed the separator they had been taught for a different field. One box appeared, labelled `de; en`; both names went into it; and the portal's language switch did nothing at all, for every product in that catalogue, with no screen anywhere saying why. The cost of the missing check was measured in weeks, and the check is a regexp.

The shape

A primary subtag of two or three letters, then any number of subtags of one to eight letters or digits, separated by single hyphens: `de`, `de-CH`, `zh-Hans`, `pt-BR`. Case is accepted as written, because a tag is compared as stored and re-casing somebody's list would move the key their texts are already under.

Deliberately narrower than BCP 47, which also admits grandfathered and private forms. A tag this refuses and the standard allows is a tag no catalogue in this product has ever carried, and the cost of that narrowness is one refusal a person can read. The cost of being liberal is the paragraph above.

Types

type Approval

type Approval struct {
	Kind ApprovalKind `json:"kind"`
	// Ref names the principal for KindFixed or the group for KindRole. It is empty
	// for the kinds that need no target, and Publish refuses a rule that needs one
	// and has none.
	Ref string `json:"ref,omitempty"`
}

Approval is one item's approval rule.

type ApprovalKind

type ApprovalKind string

ApprovalKind names how an order line for an item gets approved.

The four kinds are configuration a catalogue maintainer picks from a list; each is carried out by a process, so a fifth is modelled and registered under a name rather than built into the product. That is why this is a string and not an enum closed by the type system: KindNone, KindFixed, KindRole and KindSuperior are the shipped four, and a registered process name is as valid a value.

const (
	// KindNone orders without approval. A catalogue always answers which of its
	// items carry this, as a standing list — see ADR-0315.
	KindNone ApprovalKind = "none"
	// KindFixed routes to one named principal.
	KindFixed ApprovalKind = "fixed"
	// KindRole routes to whoever holds a named group; any one of them may approve.
	KindRole ApprovalKind = "role"
	// KindSuperior routes to the orderer's line manager, resolved from the directory.
	KindSuperior ApprovalKind = "superior"
)

type ApproverLookup

type ApproverLookup interface {
	// Account reports whether this names an account that can hold a task. Matched
	// the way an assignee is matched: by username, ignoring case.
	Account(username string) bool
	// Group reports whether this names a group, by id or by name, ignoring case —
	// which is the order and the tolerance candidate groups are matched with.
	Group(ref string) bool
}

ApproverLookup answers whether an approval reference resolves to anybody. It is two questions rather than one because the two are matched differently, and the difference is the whole reason this report exists.

type ApproverProblem

type ApproverProblem struct {
	ItemID string `json:"itemId"`
	// HomeCatalog is where the product is maintained, because that is where the
	// reader has to go to correct it.
	HomeCatalog string `json:"homeCatalog"`
	Kind        string `json:"kind"`
	// Ref is what the rule names, echoed exactly. A reference that differs from
	// what its author believes they typed is the ordinary case here, so the report
	// shows the stored string rather than a tidied one.
	Ref string `json:"ref"`
	// Why is written for the person who has to fix it, and names the thing that
	// does not exist rather than the rule that is invalid.
	Why string `json:"why"`
}

ApproverProblem is one product whose approval rule reaches nobody.

type ApproverReport

type ApproverReport struct {
	// Checked is how many products were examined, so an empty Problems list can be
	// read as "none of 240" rather than as "nothing was looked at".
	Checked  int               `json:"checked"`
	Problems []ApproverProblem `json:"problems"`
}

ApproverReport is what one read answers.

type Catalog

type Catalog struct {
	ID    string            `json:"id"`
	Texts map[string]string `json:"texts"`
	// Rank resolves the one-catalogue-per-user rule against directory group
	// membership, which is many-to-many: the highest rank a user reaches wins.
	// Ranks are unique across catalogues, and Publish refuses a tie rather than
	// falling back to an id nobody chose.
	Rank int `json:"rank"`
	// Languages are the language tags this catalogue offers. A release proves every
	// item it carries has a text for each, and a companion record forbids declaring
	// one the portal interface does not have.
	Languages []string `json:"languages"`
	// Items are the item ids this catalogue offers, whatever their home.
	Items []string `json:"items"`
	// Groups are the directory or Atlas groups whose members reach this catalogue.
	Groups []string `json:"groups,omitempty"`
	// Members are the object axis (ADR-0071/0180/0278): the role says a caller may
	// maintain catalogues at all, this says which ones. Without it a product
	// manager can rebuild and publish every customer's catalogue, with nothing in
	// the way but not knowing an id — which ADR-0278 states plainly is not an
	// access control.
	Members []Member `json:"members,omitempty"`
	// Theme is this catalogue's appearance, empty for the instance's own. Set by
	// an administrator rather than by whoever maintains the catalogue
	// (decision 12).
	Theme Theme `json:"theme,omitempty"`
	// Edges are the structure and precedence between the items this catalogue
	// offers. They belong to the catalogue rather than to the items because they
	// are what its release is computed from, and because the same two products can
	// relate differently in two catalogues.
	Edges []Edge `json:"edges,omitempty"`
	// OwnerID is whoever created it, because creation is the one moment where
	// there is nobody to ask. The owner may always read, write and share.
	OwnerID string `json:"ownerId,omitempty"`
	// Revision is optimistic concurrency, the same field and the same rule
	// [Item.Revision] and the capability map carry.
	//
	// A catalogue's patch is partial, so it cannot clear a field nobody mentioned
	// the way a product's full replace could. What it can still lose is a list:
	// Items, Edges and Members are replaced whole when sent, and the surfaces that
	// send them compute the new value from a snapshot — adding one product posts
	// every product plus that one. Two maintainers adding a product a second apart,
	// and the second write is the first one's disappearance, with no error and no
	// trace.
	//
	// Zero in a patch means the caller stated no precondition and the write is
	// unconditional, which is right for a form whose every field is on the screen
	// in front of somebody. The caller holding a snapshot is the one that should
	// say so. A catalogue itself is never at zero: it is created at one, so there
	// is always a revision to hold on to.
	//
	// Every writer advances it, and that is not a detail — a path that changes a
	// catalogue without advancing it is a path whose changes a stale caller
	// overwrites in silence. TestEveryWriterAdvancesTheRevision names them all.
	Revision  int64 `json:"revision"`
	CreatedAt int64 `json:"createdAt"`
	UpdatedAt int64 `json:"updatedAt"`
}

Catalog is a named set of items, with the appearance, audience and precedence that decide who sees it and how.

func Highest

func Highest(catalogs []Catalog) (Catalog, bool)

Highest is the top-ranked catalogue of a set, and whether there was one.

It is what "which catalogue" means when there is no audience question to ask — see Service.HandleMyCatalog for the one mode where that is the case. Rank is the tie-break the product already uses everywhere else, and publishing refuses a rank tie, which is what makes "highest" an answer rather than a coin toss.

func Resolve

func Resolve(catalogs []Catalog, groups []string) (Catalog, bool)

Resolve returns the catalogue somebody in these groups sees: the highest-ranked one they reach. The second return is false when they reach none, which is an answer and not an error — it has to be distinguishable from reaching an empty catalogue.

func (Catalog) ReachedBy

func (c Catalog) ReachedBy(groups []string) bool

ReachedBy reports whether somebody in these groups is in this catalogue's audience.

A catalogue with no audience reaches nobody. That is fail-closed on purpose: a freshly created catalogue has no groups yet, and the dangerous default is the one where it is visible to everybody while somebody is still filling it.

type Edge

type Edge struct {
	From string   `json:"from"`
	To   string   `json:"to"`
	Kind EdgeKind `json:"kind"`
}

Edge links two items. For the structural kinds From is the whole and To the part; for EdgeRequires From is the dependent item and To its precondition.

type EdgeKind

type EdgeKind string

EdgeKind distinguishes the two questions an edge can answer. They are different questions and were conflated in the record this implements: structure says what belongs to what, precedence says what must exist first.

const (
	// EdgeComposition is structure: the part is integral to the whole, always
	// ordered with it, never deselectable.
	EdgeComposition EdgeKind = "composition"
	// EdgeAggregation is structure: the part is optional, separately orderable, and
	// may belong to several wholes.
	EdgeAggregation EdgeKind = "aggregation"
	// EdgeRequires is precedence: From cannot be provisioned before To is. This is
	// the edge the fulfilment order is computed over, and the only one whose
	// direction means "after".
	EdgeRequires EdgeKind = "requires"
	// EdgeExcludes is incompatibility: From and To must never be held by the same
	// person (ADR-0342). The clerk who may create a supplier
	// must not also approve payments to it — neither right is wrong, the
	// combination is.
	//
	// A third question rather than a shading of the other two, and put beside them
	// for the reason they are apart: an edge kind that means two things is one
	// nobody can read.
	//
	// **The only symmetric kind.** The other three mean something different read
	// backwards; "A must not be held with B" is exactly "B must not be held with
	// A". [Publish] is where that is resolved, by writing both directions into the
	// release — a reader that checked one would find half the violations and report
	// the estate as half clean.
	EdgeExcludes EdgeKind = "excludes"
)

func (EdgeKind) Structural

func (k EdgeKind) Structural() bool

Structural reports whether this edge describes containment rather than order.

type FulfilmentProblem

type FulfilmentProblem struct {
	// ItemID and HomeCatalog are empty for a problem on the shared path, which
	// belongs to no single product and stops every order alike.
	ItemID      string `json:"itemId,omitempty"`
	HomeCatalog string `json:"homeCatalog,omitempty"`
	// Stage says where on the path this sits: "order", "approval", "provision" or
	// "deprovision". It is what tells a reader whether nothing can be ordered at
	// all or only this one service cannot be given back.
	Stage string `json:"stage"`
	// ProcessID is the process the binding names, echoed exactly.
	ProcessID string `json:"processId"`
	// JobType is set where the process is deployed and one of its job types is
	// unserved. Empty where the process itself is missing.
	JobType string `json:"jobType,omitempty"`
	// Why is written for the person who has to fix it and names the thing that is
	// absent, not the binding that is invalid.
	Why string `json:"why"`
}

FulfilmentProblem is one thing that would stop an order, named where the reader has to go to fix it.

type FulfilmentReport

type FulfilmentReport struct {
	// Checked is how many offered services were examined, so an empty Problems
	// list reads as "none of 10" rather than as "nothing was looked at".
	Checked int `json:"checked"`
	// Releases names the releases the products came from, so a reader can tell
	// whether the report is about what they just published.
	Releases []string            `json:"releases"`
	Problems []FulfilmentProblem `json:"problems"`
}

FulfilmentReport is what one read answers.

type Import

type Import struct {
	Items []Item `json:"items"`
	Edges []Edge `json:"edges"`
	// Skipped names what was left out and why, in the model's own words where it
	// has them.
	Skipped []string `json:"skipped"`
}

Import is what a model yielded: the drafts, the structure between them, and everything that did not come across.

func ImportArchiMate

func ImportArchiMate(data []byte, homeCatalog string) (Import, error)

ImportArchiMate reads an ArchiMate Open Exchange document and derives catalogue drafts from it. The returned records are not stored; a caller compares them with what it has and decides.

type Input

type Input struct {
	// CatalogID names the catalogue this publish is *of*. Everything computed
	// below — the schedule, the structure, the standing lists — is that one
	// catalogue's, and [Items] and [Edges] are its own.
	//
	// The other catalogues are here for one question, and only one: a rank is
	// unique across the set, so a tie can only be seen against everybody else. It
	// was easy to miss that the rest of the set is not the subject, and missing it
	// cost a real defect — every other catalogue's products were resolved against
	// *this* catalogue's item list, so every one of them came back "unknown item",
	// and a catalogue could be published only while every other catalogue was
	// empty. Publishing A blamed B, publishing B blamed A, and neither message
	// named the catalogue the caller had asked to publish.
	//
	// Empty means "there is no other catalogue to be", which is true of a single
	// one and of nothing else — see [Publish], which refuses the ambiguous case
	// rather than picking a reading.
	CatalogID string
	Catalogs  []Catalog
	Items     []Item
	Edges     []Edge
}

Input is the whole of what a publish is computed from. It is passed rather than read so the computation is a pure function of its argument: the same input produces the same release and the same problems, which is what lets a release be diffed and a refusal be reproduced.

type Item

type Item struct {
	ID string `json:"id"`
	// HomeCatalog is the catalogue whose scope governs editing this item. An item
	// is referenced by catalogues rather than owned by one — it legitimately appears
	// in several — so scope cannot be inherited from "the catalogue it is in", and a
	// per-item member list is the per-artifact ACL ADR-0071 refused. One home, and
	// every other catalogue includes it read-only.
	HomeCatalog string `json:"homeCatalog"`
	State       State  `json:"state"`
	// Texts holds the item's name per language tag. A release proves it carries one
	// for every language its catalogue declares.
	Texts map[string]string `json:"texts"`
	// Descriptions is what this product *is*, per language tag, beside the name
	// Texts carries. Empty is the ordinary state for a product whose name says
	// enough, and means no description anywhere rather than an empty one.
	//
	// # Why per language and not one string
	//
	// Every other text a person reads off a product is per locale, and this is the
	// one somebody reads when the name was not enough — which is exactly when not
	// being able to read it matters. A catalogue declaring German and French could
	// otherwise translate the name of a thing and not the sentence explaining it.
	//
	// It is deliberately not Keywords, which is flat for the opposite reason:
	// keywords are for *finding* and a searcher's language is not the catalogue's,
	// while a description is for *showing* and is read in the language the portal
	// is being read in.
	//
	// # What a release demands of it
	//
	// Nothing, until there is one. A product with no description publishes, because
	// not every product needs a paragraph. A product with a description in one of
	// the catalogue's declared languages and not another does not, because that is
	// a portal where one audience is told what the thing is and the other is shown
	// an empty panel — the half-translated catalogue the name check already
	// refuses, in the field a reader turns to second.
	Descriptions map[string]string `json:"descriptions,omitempty"`
	Lifecycle    Lifecycle         `json:"lifecycle,omitempty"`
	Variants     []Variant         `json:"variants,omitempty"`
	Approval     Approval          `json:"approval"`
	// ProvisionProcess and DeprovisionProcess are the BPMN process ids bound to this
	// item. Both are required to publish: a catalogue that can only grant is not a
	// lifecycle, and the day somebody must revoke at scale is the wrong day to find
	// the process was never written.
	ProvisionProcess   string `json:"provisionProcess"`
	DeprovisionProcess string `json:"deprovisionProcess"`
	// MultipleAllowed says whether a principal may hold this item more than once —
	// two licences, two mailboxes. Where it is false the basket marks an item the
	// orderer already holds as held, and skips it.
	MultipleAllowed bool `json:"multipleAllowed,omitempty"`
	// Targets is what this item is called in the systems that actually hold it, and
	// it is what lets a commissioning load attribute a right it found to this item
	// (ADR-0333). Empty is the ordinary state for an
	// item nothing outside Atlas grants, and it means no load will ever name it.
	//
	// Several are allowed: one service is legitimately two groups. The same ref
	// twice is not, and [Publish] refuses it — an observation matching two items
	// cannot be attributed, and guessing between them would write evidence Atlas
	// invented.
	Targets []TargetRef `json:"targets,omitempty"`
	// MaxDays is how long a right this product grants may last, in days, or zero
	// for one that does not end (ADR-0344). It is a
	// **ceiling declared as policy** — nobody holds this for more than ninety days
	// — and not a date somebody chose; an order cannot yet name a shorter end
	// within it.
	//
	// It reaches a grant through the release, like the bindings and the approval
	// rule beside it: a ceiling relaxed in the catalogue next week must not lengthen
	// a right granted this week under the stricter one.
	//
	// It never applies to an adopted or legacy right. A commissioning load records
	// a found right's start as the moment it was found, so a ceiling measured from
	// it would schedule an entire estate to expire on the anniversary of the day
	// somebody switched the portal on.
	MaxDays int `json:"maxDays,omitempty"`
	// Eligible narrows the catalogue's audience for this one product: the group ids
	// whose members may **receive** it. Empty is the ordinary case and means no
	// narrowing (ADR-0347).
	//
	// # Why an item needs this when a catalogue already has an audience
	//
	// Because a person sees exactly one catalogue — the highest-ranked one their
	// groups reach — so "put it in a narrower catalogue" is not a workaround. A
	// second catalogue does not give anybody a second shop; it is simply hidden
	// behind the one they already reach. A product that should be available to part
	// of a catalogue's audience could not be expressed at all.
	//
	// # Why empty is not fail-open
	//
	// [Catalog.ReachedBy] is fail-closed and stays the gate: nothing here is
	// reachable by somebody outside the catalogue's audience, whatever this says.
	// This only ever *narrows* that, so an empty list inherits a restriction rather
	// than removing one — the opposite of a catalogue with no groups, which would
	// otherwise be a shop open to everybody while somebody is still filling it.
	//
	// # Who is checked
	//
	// The **recipient**, never the orderer. A manager ordering a workplace for a
	// new hire is the ordinary case and must keep working; the question this
	// answers is who may end up holding the thing, and that is the person it is
	// for. It reaches an order through the release like the ceiling and the
	// approval rule beside it, so a restriction relaxed next week cannot retroact
	// on an order placed this week and a restriction *added* next week cannot
	// invalidate one already approved.
	Eligible []string `json:"eligible,omitempty"`
	// Keywords are the words somebody might search for that are not the product's
	// name: synonyms, the vendor's own term, the abbreviation everybody uses, the
	// thing it replaced (ADR-0355).
	//
	// # Why this exists rather than searching the name alone
	//
	// The story it serves names the case exactly: find a service *when the exact
	// product name is not known*. A search over names answers only for somebody who
	// already knows what the thing is called, which is the person who least needs
	// a search.
	//
	// # Why one flat list and not one per language
	//
	// Every other text on an item is per locale, and this deliberately is not. A
	// synonym list is for **finding**, not for displaying, and a searcher's language
	// is not the catalogue's: somebody reading a German catalogue types "laptop" as
	// readily as "Notebook", and an abbreviation like "M365" belongs to no language
	// at all. Splitting the list by locale would hide a term from exactly the person
	// who needed it, and would ask a product manager to maintain in two places what
	// they think of once.
	//
	// It travels into the release like everything else here, so a term added next
	// week does not change what an order placed this week was placed against — which
	// matters less for a search than for a rule, and is still the property that
	// makes a release a release.
	Keywords []string `json:"keywords,omitempty"`
	// ConfigForm names the Atlas form somebody fills in when they order this
	// product: the cost centre, the site, the employee number — whatever this one
	// product needs that its name does not say
	// (ADR-0358). Empty is the
	// ordinary case and means the product is fully described by what it is.
	//
	// # Why a form id and not a field list of its own
	//
	// Atlas already has forms: a definition with an id, an editor, a renderer, a
	// generator, and two surfaces rendering them. A second way to declare "these
	// are the fields somebody fills in" would be a second thing to author, a second
	// thing to render, and a second set of field types to keep level with the
	// first. The catalogue names an id and interprets nothing.
	//
	// # What the catalogue does *not* do with it
	//
	// It never resolves it. The form store is the `api` package's and this package
	// cannot see it, exactly as it cannot see which processes are deployed — and
	// for the same reason the two provisioning bindings are stored as plain ids.
	// The authoring screen offers only forms that exist, which is where that check
	// belongs: at the moment somebody chooses, not at the moment somebody orders.
	//
	// # Why the release freezes the id and not the form
	//
	// A release freezes what an order was placed against, and the reason is always
	// the same: a rule relaxed next week must not change what somebody was held to
	// this week. That argument is about *rules*. A form is a set of questions, and
	// what has to survive is the **answers** — which the order line carries, with
	// their field keys, for as long as the order exists. "Cost centre 4711" stays
	// true whatever the form does afterwards.
	//
	// The honest cost is stated in the record: a form that gains a field next week
	// leaves every earlier order without a value for it, and a provisioning process
	// that reads it finds nothing. That is true of every variable a process reads
	// and is the process's business; copying a form-js document into every release
	// would put a rendering artifact inside a design-time model that has kept
	// rendering out of itself on purpose, and would send it to every browser that
	// opens the portal.
	ConfigForm string `json:"configForm,omitempty"`
	// Price is what this product costs, written as the catalogue's maintainer wants
	// it read — "CHF 1'200.–", "49.– / Monat", "im Grundpaket enthalten"
	// (ADR-0361). Empty is the ordinary case
	// and means the catalogue says nothing about cost.
	//
	// # Why a string and not a number with a currency
	//
	// Because it is **displayed and never computed**. A number invites a total, a
	// total invites two products in different currencies, and that invites a rate
	// and a date — a money model, decided by an installation's finance rules and not
	// by this package. Everything that makes a price *arithmetic* is absent on
	// purpose, and a string is the honest shape of "this is what it says on the
	// shelf".
	//
	// The cost of that is stated rather than hidden: nothing can add these up. The
	// surface that needs a figure most is the approval, and an approval decides one
	// line — so the one number it shows is the one number it needs. A basket total
	// would need the money model above, and that is a different measure.
	//
	// # Why it is frozen into the release like a rule
	//
	// It is not a rule, and it travels like one anyway. An approver saw a figure and
	// decided on it; a catalogue edit next week must not make the record show a
	// different figure than the one that was approved. That is the same sentence as
	// the approval rule's and the ceiling's, and it is the reason this is on the
	// line as well as in the release.
	Price string `json:"price,omitempty"`
	// Category is the heading this product sits under in the portal — "Arbeitsplatz",
	// "Kommunikation", "Fachanwendungen" (ADR-0360).
	// Empty is the ordinary case and puts the product under the heading the portal
	// names for products that have none.
	//
	// # Why a field and not an entity
	//
	// Because it is **a heading and nothing else**. An entity would carry its own
	// texts, its own ordering, its own visibility and its own lifecycle, and each of
	// those is a thing to publish, migrate and get wrong. A heading has none of them:
	// it is the word above a column.
	//
	// The costs are real and stated rather than hidden. A category has **no ordering
	// of its own** — there is nothing on a string to sort by, and inventing a rank
	// here would be the entity arriving through the back door, so the portal sorts
	// alphabetically. And two spellings are two categories, with nothing to notice
	// that "Arbeitsplatz" and "Arbeitsplätze" were meant as one.
	//
	// If either of those turns out to matter, the answer is the entity, and this
	// field is what it would be migrated from.
	//
	// # What this string is, now that the heading is translatable
	// (ADR-0412)
	//
	// The third cost ADR-0360 recorded was that a heading has no translation. It has
	// one now, in [Item.CategoryTexts] below — and this field did not become it. It
	// is **the key**: what the portal groups by, what a search hit sets to open the
	// cascade at the right column, and what an already published release already
	// holds. The translations are how it is *rendered*.
	//
	// That split is the whole of the decision, and it is what makes the change
	// additive. Had the string become a map, two products would be one category when
	// their German agreed and two when their French did not — a heading that splits
	// in a language nobody publishing it reads. Grouping by one value that no reader
	// ever has to see keeps "same category" a fact about the catalogue rather than
	// about the reader.
	//
	// Where translations are present the key never has to be a word: a stable slug
	// survives the maintainer deciding the German wording was wrong. Where they are
	// absent — every product written before the field existed, every single-language
	// catalogue — the key is the word, and it is what every language renders. That
	// is the old behaviour exactly, which is why no stored record had to be migrated.
	Category string `json:"category,omitempty"`
	// CategoryTexts is [Item.Category] per language tag: the heading as the reader
	// of this portal reads it, where the key above is what everything groups by.
	//
	// Empty is the ordinary state and means the key renders in every language — the
	// state of every product written before this field and of every catalogue that
	// declares one language.
	//
	// # What a release demands of it
	//
	// The rule [Item.Descriptions] follows, for the same reason: optional as a
	// whole, all-or-nothing once there is one. A heading translated into German and
	// not French is a portal where one audience reads its own column head and the
	// other reads somebody else's, and nothing says so, because the fallback renders
	// and looks deliberate.
	//
	// Beside that, a translation without a key is refused. It would put the product
	// in the bucket for products carrying no heading — under a column head reading
	// "Ohne Kategorie", while holding the word for one in four languages.
	CategoryTexts map[string]string `json:"categoryTexts,omitempty"`
	// ProductGroup is the group this product sits in, one level below its category:
	// the portal's cascade reads Kategorie > Produktgruppe > Produkt > Services.
	//
	// A string on the product, exactly like [Item.Category] above, and for the same
	// reasons — see the paragraph there, which applies word for word. What is worth
	// adding is what the pairing means, because "a chain" sounds like more than this
	// is: the group has no record and therefore **no category of its own**. The
	// product carries both strings and the chain is assembled per product — this
	// product is in category X, group Y — so the group→category relation is only
	// ever read off the products that carry both.
	//
	// Two consequences, and neither is an error state. A group whose products sit
	// in two categories appears under both, because nothing anywhere claims a group
	// belongs to one. And a group with no products does not exist, exactly as a
	// category with none does not.
	ProductGroup string `json:"productGroup,omitempty"`
	// ProductGroupTexts is [Item.ProductGroup] per language tag, exactly as
	// [Item.CategoryTexts] is the heading's — same split into a key that groups and
	// texts that render, same all-or-nothing rule at publication, same reason. See
	// the paragraphs there.
	ProductGroupTexts map[string]string `json:"productGroupTexts,omitempty"`
	// Revision is optimistic concurrency, the same field and the same rule the
	// capability map uses: a write that states a revision is refused when the
	// stored record has moved past it, rather than silently overwriting somebody
	// else's edit (ADR-0376).
	//
	// # Why this and not the timestamp beside it
	//
	// Because a caller has to be able to send it back unchanged, and
	// [Item.UpdatedAt] is Unix nanoseconds — around 1.8e18, past the 2^53 where a
	// float64 stops representing integers exactly. Every JSON client that decodes
	// numbers as doubles, which is most of them and every MCP client, would return
	// a value a few hundred nanoseconds off and be told its write was stale. A
	// revision counts in ones, so it stays exact for more writes than a catalogue
	// will ever see.
	//
	// Zero means the caller stated no precondition and the write is unconditional,
	// which is what the Console sends: it renders every field and posts every
	// field, so the record it overwrites is the record on the screen in front of
	// somebody. A product stored before this field existed also reads zero, so its
	// first write is unconditional and every write after it can be guarded.
	Revision  int64 `json:"revision"`
	CreatedAt int64 `json:"createdAt"`
	UpdatedAt int64 `json:"updatedAt"`
}

Item is a product or a service. Which of the two it is follows from its position in the graph, not from a field: an item nothing composes is a product, an item with no parts is a service, and an item with both is a bundle inside a bundle. Levels nest arbitrarily.

type Lifecycle

type Lifecycle struct {
	From  int64 `json:"from,omitempty"`
	Until int64 `json:"until,omitempty"`
}

Lifecycle is the window in which an item may be ordered, in the server's own Unix time (nanoseconds, as every other timestamp in this tree).

Zero means unbounded on that side, which is the ordinary case: most products are orderable from the moment they are published until somebody withdraws them. The window is data rather than a computed state so that a catalogue can be published ahead of the date it opens.

**Both ends are inclusive**, and placing an order outside them is refused (ADR-0397). The gate is the order service, read from the release like every other rule about a basket; the portal keeps the same window so a basket cannot be filled with something the placement will refuse, which is courtesy rather than enforcement. The Console authors the window as two days and stores the **end** of the last one, so a product orderable "until the 31st" is orderable on the 31st.

It governs ordering and nothing else. A right already held when the window closes keeps running; when a right ends is Item.MaxDays, which is a different question (ADR-0344).

type Member

type Member struct {
	Ref  PrincipalRef `json:"ref"`
	Role MemberRole   `json:"role"`
}

Member is one grant on a catalogue.

type MemberRole

type MemberRole string

MemberRole is what a member may do. The two values are ADR-0071's, unchanged: a viewer reads, an editor reads and writes. Ownership is the implicit third and highest, and is not listed.

const (
	// RoleViewer reads the catalogue.
	RoleViewer MemberRole = "viewer"
	// RoleEditor reads and changes it, publishing releases included.
	RoleEditor MemberRole = "editor"
)

type PrincipalRef

type PrincipalRef struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

PrincipalRef names who a grant is for. Type is "user" or "group"; a group grant reaches everyone in it, resolved from the membership the principal already carries, so no store read happens inside an authorization check (ADR-0180).

type Problem

type Problem struct {
	// Catalog and Item name where the problem is, either or both empty when it
	// belongs to the whole input (a cycle, a rank tie).
	Catalog string `json:"catalog,omitempty"`
	Item    string `json:"item,omitempty"`
	Message string `json:"message"`
}

Problem is one reason a catalogue cannot be published. Publish returns every problem it finds rather than the first: an author who fixes one refusal per attempt is an author who eventually works around the gate.

func TranslationGaps

func TranslationGaps(in Input) []Problem

TranslationGaps is every place a catalogue is written in one of its declared languages and not another (ADR-0414).

It is the half of a rule that used to be a refusal. Publishing demanded a name for every declared language, and a description and a heading wording in all of them once there was one in any, on the argument that a shop showing one audience a product and the other an empty row is worse than no catalogue at all. The argument was sound and the premise was not: the shop never shows an empty row, it falls back to whatever language the catalogue does have, because a name in the wrong language is better than no name. So the refusal was not protecting a reader — it was holding a usable catalogue back until the last translation arrived, which meant the first language's readers waited on the second language's translator.

A publish answers two questions and they are not the same question. **Can this be ordered against**, which is what a refusal is for and what Publish still decides; and **is this as good as it should be**, which is a thing to be told rather than blocked by. This is the second one, and it exists because removing a gate and putting nothing in its place is how a half-translated catalogue becomes invisible again — the failure the gate was built against.

What counts as a gap

A field is a gap where the product says it in at least one language and not in this one — the name, the description, the two headings, and the name of every shape it is ordered in. A product nobody described has no description gap, a product carrying no heading has no heading gap, and a heading carrying its key and no wordings has none either — the key renders in every language, which is what carrying no wording means, and which is every product written before the wordings existed. Reporting those would name the whole installed base on the first screen somebody opens.

A product named in no language at all is not reported here. It is refused by Publish, because there is nothing to fall back to.

Why per catalogue and per language

The languages are the *catalogue's*, and one product is offered by several catalogues declaring different ones (ADR-0315). The same missing French is a gap in the catalogue that declares French and no statement at all about the one that does not, so a gap names both the catalogue and the item, and a maintainer reading the list can open the thing it is about.

Pure, like Publish and for the same reason: the same input answers the same gaps, so a report can be reproduced from what it was computed over.

func (Problem) String

func (p Problem) String() string

type ProcessLookup

type ProcessLookup interface {
	// JobTypesOf returns the job types the tasks of this process id carry, and
	// whether any executable definition carries that id at all. The newest
	// deployed version answers, because that is the one an instance starts on.
	JobTypesOf(processID string) (jobTypes []string, deployed bool)
	// Served reports whether anything works that job type: the engine itself, a
	// worker seen pulling it, or — for a user task — a person.
	Served(jobType string) bool
}

ProcessLookup answers what the engine knows about the processes a catalogue names. Two questions, because a binding fails in two different ways and the difference is what the reader has to act on: a process that was never deployed is a name to correct, and a deployed process with a job type nobody pulls is a worker to start.

type Release

type Release struct {
	// ID, CatalogID and CreatedAt identify the release. They are assigned by
	// whoever publishes it; [Publish] computes the rest.
	ID        string `json:"id,omitempty"`
	CatalogID string `json:"catalogId,omitempty"`
	CreatedAt int64  `json:"createdAt,omitempty"`
	// Items are the item definitions as they stood when this was published, sorted
	// by id, and they are copies rather than references.
	//
	// This is the whole of the snapshot rule. An order names one release; if the
	// release pointed at rows that keep changing, an approval pending for a week
	// could be approving something else by the time it is granted. Only value data
	// is copied, so an edit to the catalogue afterwards cannot reach in here.
	Items []Item `json:"items,omitempty"`
	// Waves is every item in the input, grouped into rounds: nothing in a wave
	// depends on anything in the same wave, and everything it does depend on is in
	// an earlier one. An order names one release and follows this schedule; it
	// never recomputes it.
	//
	// A flat sequence was the first shape and could not carry the failure
	// behaviour the shop requires: a failing line must stop only the lines that
	// depend on it, and a list has already discarded the reason each item sits
	// where it does. Waves keep it — a failure stops its own successors, and the
	// rest of its wave and every independent branch continue.
	Waves [][]string `json:"waves"`
	// Requires holds each item's direct preconditions, sorted, with no entry for an
	// item that has none.
	//
	// Waves are the right unit to *run* in and the wrong unit to *start on*. At a
	// wave boundary all that is known is "the previous wave is done", which cannot
	// distinguish a line whose precondition failed from one whose precondition is
	// in the same wave and succeeded — so a wave-wide barrier either starts a line
	// whose precondition is missing, or holds one whose preconditions are all
	// there. Both are wrong, so the edges travel with the schedule and [Blocked] is
	// what the fulfilment process asks when a line fails.
	Requires map[string][]string `json:"requires,omitempty"`
	// Excludes is what must never be held together with what
	// (ADR-0342), sorted, with no entry for an item that
	// excludes nothing.
	//
	// **Every pair appears under both ids.** EdgeExcludes is the only symmetric
	// edge kind, and a release recording one direction would make each reader
	// responsible for knowing which — a reader that got it wrong would find half
	// the violations and report the estate as half clean.
	Excludes map[string][]string `json:"excludes,omitempty"`
	// Includes and Options are the structure: what a product is made of, and what
	// is offered alongside it. Both are direct edges, sorted, with no entry for a
	// product made of nothing.
	//
	// They are kept apart because they mean opposite things to a basket. An
	// inclusion is a consequence of ordering the whole — integral, never
	// deselectable — and an option is an offer. Merging them would order a second
	// screen for everybody who orders a workplace.
	Includes map[string][]string `json:"includes,omitempty"`
	Options  map[string][]string `json:"options,omitempty"`
	// WithoutApproval names every item orderable with no approval at all, sorted.
	// It is a standing list rather than a report somebody has to think to run,
	// because the role that defines approval rules is the role that publishes them.
	WithoutApproval []string `json:"withoutApproval"`
}

Release is a frozen, published catalogue: what may be ordered, in what order it is fulfilled, and what it grants without asking anybody.

func (Release) Blocked

func (r Release) Blocked(failed ...string) []string

Blocked reports every item that cannot run because one of the given items failed, transitively, sorted and each named once.

The failed items themselves are not in the result: they have a failure of their own to report, and listing them as blocked would send somebody looking in the wrong place. An unknown id blocks nothing rather than erroring — a fulfilment process asking about a line that is not in this release has a different problem, and inventing one here would hide it.

func (Release) Expand

func (r Release) Expand(selected []string) []string

Expand resolves a selection into everything it actually orders: the chosen products, plus every product any of them is made of, all the way down.

Options are not pulled in — an aggregation is an offer, and including it automatically is how somebody ends up with a second screen they never asked for. Each product appears once however many selected products contain it, which is the deduplication the same service in two bundles needs.

Anything the release does not carry is ignored rather than invented: a request naming a product that was withdrawn, or never existed, must not become a line that nothing can provision.

type Service

type Service struct {

	// Limits are the installation's resource budgets. New sets them to
	// [limits.Default]; the server overwrites them with its own once it has read
	// the environment.
	Limits limits.Limits

	// Approvers resolves an approval rule's reference to somebody, for the report
	// in approverreport.go. It is a settable field for the reason Limits is: the
	// accounts and the groups belong to the server, which builds this service
	// before it has them.
	//
	// Nil is a real state and not a bug — a Service built by a test that does not
	// care about approvals has none — so the report refuses rather than guessing.
	// Both guesses are available and both are worse than a refusal: every
	// reference unresolvable reports the whole catalogue as broken, and every one
	// resolvable reports a clean estate nobody checked.
	Approvers ApproverLookup

	// Processes answers what is deployed and what is worked, for the report in
	// fulfilmentreport.go. Settable for Approvers' reason and nil for Approvers'
	// reason: the deployments and the workers belong to the server, and a Service
	// built without them refuses the report rather than guessing at it.
	Processes ProcessLookup
	// contains filtered or unexported fields
}

Service is the catalogue API area.

func New

func New(loop *runloop.Loop, store *Store, now func() int64, admin func(*httpapi.Principal) bool) *Service

New builds the service. Every dependency is an explicit argument (ADR-0147).

func (*Service) HandleApproverReport

func (s *Service) HandleApproverReport(w http.ResponseWriter, r *http.Request)

HandleApproverReport answers which of the products this caller maintains name an approver that reaches nobody.

Scoped to what the caller may edit, like the product list it reads: the report names products and the people who approve them, which is maintenance information and not a public index of who approves what.

func (*Service) HandleCreateCatalog

func (s *Service) HandleCreateCatalog(w http.ResponseWriter, r *http.Request)

HandleCreateCatalog creates one.

func (s *Service) HandleDeleteLogo(w http.ResponseWriter, r *http.Request)

HandleDeleteLogo removes a catalogue's brand mark, so the portal falls back to the operator's. Same gate as setting one: taking a brand away is as much a change to what a customer group sees as putting one there.

func (*Service) HandleDeletePicture

func (s *Service) HandleDeletePicture(w http.ResponseWriter, r *http.Request)

HandleDeletePicture removes a product's picture. Same gate as setting one: taking the picture away changes how the product is offered as much as putting one there.

func (*Service) HandleFulfilmentReport

func (s *Service) HandleFulfilmentReport(w http.ResponseWriter, r *http.Request)

HandleFulfilmentReport answers which of the services this caller offers cannot be fulfilled on this installation.

Scoped to the catalogues the caller may edit, like the approver report and the product list: it names processes and job types, which is maintenance information about this installation's wiring.

func (*Service) HandleGetCatalog

func (s *Service) HandleGetCatalog(w http.ResponseWriter, r *http.Request)

HandleGetCatalog reads one. A missing catalogue is 404 rather than an empty object: a reader has to be able to tell "no such catalogue" from "a catalogue with nothing in it".

func (s *Service) HandleGetLogo(w http.ResponseWriter, r *http.Request)

HandleGetLogo serves a catalogue's brand mark, or 404 when it has none.

Unlike the instance's mark this one is *not* public. ADR-0113's logo is shown on the sign-in screen and has to be reachable before anybody is known; a catalogue's is shown inside the portal, to the group the catalogue is for. An open endpoint here would answer "does catalogue X exist" to anybody who asked, which is the catalogue-name oracle the theme record refused to open — and it would hand a customer's mark to every other customer on the instance.

So it is the catalogue's own read right, and a catalogue somebody may not read answers 404 rather than 403: the two have to be indistinguishable, or the status code is the oracle the missing endpoint would have been.

func (*Service) HandleGetPicture

func (s *Service) HandleGetPicture(w http.ResponseWriter, r *http.Request)

HandleGetPicture serves a product's picture, or 404 when it has none.

A product nobody may see and a product with no picture are the same 404, on purpose: the two have to be indistinguishable, or the status code answers the question the gate withholds.

func (*Service) HandleImport

func (s *Service) HandleImport(w http.ResponseWriter, r *http.Request)

HandleImport reads an ArchiMate model and files what it finds into a catalogue, as drafts.

It needs the authority to change the catalogue, because that is what it does. It never overwrites a product that is already stored: the model is where a catalogue starts, not something it follows (decision b4), and a second import after somebody bound processes and activated a product must not undo that work. What it left alone it says, in the same report as what it could not take — both are things the person who ran it needs to know.

func (*Service) HandleListCatalogs

func (s *Service) HandleListCatalogs(w http.ResponseWriter, r *http.Request)

HandleListCatalogs lists the catalogues the caller maintains, lowest rank first. It is the maintenance sight, not the portal one: being the audience for a catalogue puts nothing in this list, and GET /shop/catalog answers that question instead. A customer must not learn from a listing which other customers exist.

func (*Service) HandleListItems

func (s *Service) HandleListItems(w http.ResponseWriter, r *http.Request)

HandleListItems lists the products whose home catalogue the caller maintains. A product's rules — its approval, its process bindings — belong to whoever is responsible for it, and the portal reads what it may order from a release rather than from here.

func (*Service) HandleListReleases

func (s *Service) HandleListReleases(w http.ResponseWriter, r *http.Request)

HandleListReleases lists a catalogue's releases, newest first.

func (*Service) HandleMyCatalog

func (s *Service) HandleMyCatalog(w http.ResponseWriter, r *http.Request)

HandleMyCatalog answers the first question of every portal session: which catalogue is mine.

With enforcement on, it is the highest-ranked catalogue the caller's groups reach, and reaching none is 404 rather than an arbitrary catalogue — showing somebody a shop they are not the audience for is worse than showing them nothing.

The single-user mode

With `--auth=false` there is no principal, so there are no groups, so Catalog.ReachedBy answers false for every catalogue and the portal could never resolve one. The documented development and demo mode showed an empty page and said a catalogue had not been assigned — to somebody there is no "you" to assign one to (ADR-0377).

So when there is nobody to be, the audience question is not asked and the highest-ranked catalogue is the answer. That relaxes a rule this package is otherwise strict about — a catalogue with no audience reaches nobody, fail-closed on purpose — and the reason it is safe to relax *here* and nowhere else is that the guard protects nothing in this mode: with enforcement off every catalogue is already readable through the administration routes by anybody who can reach the port. It withholds a catalogue from a person who does not exist, at the cost of the one screen the mode is for.

It is confined to a **nil** principal. A signed-in administrator still gets their own audience's catalogue and not the top-ranked one: being allowed to read every catalogue is not the same as being the audience for one, and the portal answers the second question.

func (*Service) HandlePublish

func (s *Service) HandlePublish(w http.ResponseWriter, r *http.Request)

HandlePublish validates a catalogue and writes the release.

A refused publish writes nothing: the problems are the response, and a release is only created once every one of them is gone. Reporting them all at once is deliberate — an author who fixes one refusal per attempt is an author who eventually works around the gate.

func (*Service) HandleSaveItem

func (s *Service) HandleSaveItem(w http.ResponseWriter, r *http.Request)

HandleSaveItem writes a product, creating or replacing it.

The id comes from the caller rather than being minted here: a product is referred to by name in a model, an import and a catalogue's item list, so it is a chosen identifier and not an opaque one.

Replacing safely

The write is a full replace, which is the right shape for the form that sends every field it rendered and the wrong shape for anything that changes one field of a record it read earlier: a concurrent change is overwritten with nothing to say it existed. So a caller may state the Item.Revision it read, and the write is refused as a conflict unless the stored record still carries it (ADR-0376). It is the rule the capability map already uses, spelled the same way, so a reader who knows one knows the other.

Stating it is optional, and that is deliberate rather than a transition: the Console builds its body from form fields and knows no revision, so demanding one would be a breaking change to the surface that has no concurrency problem — a person editing a form is looking at what they are about to overwrite. The precondition is for the caller that is not a person.

func (s *Service) HandleSetLogo(w http.ResponseWriter, r *http.Request)

HandleSetLogo stores a catalogue's brand mark from the raw request body.

Administration, like the theme (decision 12) and for the same reason, with one of its own: this is the only place in the catalogue where a person supplies bytes that the server later serves back to a browser. Every check that makes that safe is brandimage's, and none of it reaches a lesser role.

The body is read before the loop is entered. Reading a request body is waiting on a network, and the single writer must not wait on anybody (invariant I3).

func (*Service) HandleSetPicture

func (s *Service) HandleSetPicture(w http.ResponseWriter, r *http.Request)

HandleSetPicture stores a product's picture from the raw request body.

The gate is the one that changes the product: whoever maintains its home catalogue. A picture is part of how the product is offered, and somebody who may not rename it may not re-illustrate it either.

The body is read before the loop is entered. Reading a request body is waiting on a network, and the single writer must not wait on anybody (invariant I3).

func (*Service) HandleSetTheme

func (s *Service) HandleSetTheme(w http.ResponseWriter, r *http.Request)

HandleSetTheme sets or clears a catalogue's appearance.

Administration, not catalogue maintenance (decision 12). That is the smaller change: the logo upload keeps the gate it has, and no new file-validation surface reaches a lesser role. The cost is that ten brands queue behind one administrator — acceptable, because a brand changes rarely and a catalogue's contents change weekly.

func (*Service) HandleTranslationGaps

func (s *Service) HandleTranslationGaps(w http.ResponseWriter, r *http.Request)

HandleTranslationGaps answers it for every catalogue the caller may edit.

Scoped to editors and not to readers, because it is a to-do list and a to-do list handed to the audience is a list of what the shop has not finished.

func (*Service) HandleUnpublished

func (s *Service) HandleUnpublished(w http.ResponseWriter, r *http.Request)

HandleUnpublished answers what publishing this catalogue would change.

Read authority and not write, the same as listing the releases: it says nothing the caller cannot already assemble from GET on the catalogue and on its releases. Requiring the authority to publish would withhold the answer from exactly the person who needs to ask somebody else to.

func (*Service) HandleUpdateCatalog

func (s *Service) HandleUpdateCatalog(w http.ResponseWriter, r *http.Request)

HandleUpdateCatalog changes what a catalogue offers.

func (*Service) MayMaintain

func (s *Service) MayMaintain(c Catalog, p *httpapi.Principal) bool

MayMaintain reports whether p may see this catalogue as somebody who maintains it, rather than as somebody it is offered to.

It is [mayRead] without the audience, and the difference is the whole reason it exists. A customer reaches a catalogue to order from it; that tells them nothing about the estate behind it, and a surface that drew the catalogue's products, their provisioning processes and the engine around them for everyone the catalogue reaches would be using this store to answer a question it never agreed to answer. Maintainers, and the people a maintainer shared it with, are who that picture is for (ADR-0211 §3).

func (*Service) MayOrderFrom

func (s *Service) MayOrderFrom(p *httpapi.Principal, catalogID string) (bool, error)

MayOrderFrom reports whether this principal may place an order against the given catalogue.

Two ways in, and they answer different questions. The audience is the ordinary one: which catalogue somebody orders from is a fact about *them* — their groups — rather than about their authority, so no role opens a customer's catalogue to somebody outside it. The second is whoever maintains the catalogue, because otherwise nobody can check what they built and the first real order is the test.

It runs on the loop, so callers must not already hold it.

type State

type State string

State is where an item stands in its own life.

There is no deleted state, and that is load-bearing: an order placed years ago and an entitlement still held both resolve through the item, so an item is withdrawn rather than removed. A store that can delete one can orphan evidence.

const (
	// StateDraft is an item being written. It cannot be published.
	StateDraft State = "draft"
	// StateActive is an item a release may carry.
	StateActive State = "active"
	// StateWithdrawn is an item no longer orderable, still resolvable forever.
	StateWithdrawn State = "withdrawn"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store holds a catalogue's three kinds of record, and the one thing a catalogue owns that is not a record: its brand mark (see logo.go).

func NewStore

func NewStore(dir string) (*Store, error)

NewStore opens (creating if needed) the directories backing a catalogue.

func (*Store) Catalog

func (s *Store) Catalog(id string) (Catalog, bool, error)

Catalog reads one catalogue. A missing record is not an error: callers have to be able to tell "there is none" from "the store is broken".

func (*Store) Catalogs

func (s *Store) Catalogs() ([]Catalog, error)

Catalogs lists every catalogue, lowest rank first.

func (s *Store) ClearLogo(catalogID string) error

ClearLogo removes a catalogue's mark, whatever format it was in. A catalogue that had none is not an error: the caller asked for a state, and it holds.

func (*Store) ClearPicture

func (s *Store) ClearPicture(itemID string) error

ClearPicture removes an item's picture, whatever format it was in. An item that had none is not an error: the caller asked for a state, and it holds.

func (*Store) InputFor

func (s *Store) InputFor(catalogID string, edges []Edge) (Input, bool, error)

InputFor gathers what Publish validates for one catalogue: the catalogue itself, every item it offers, and the edges between them. Every catalogue is passed rather than only this one, because the rank-uniqueness check is about the set and not about any single record.

func (*Store) Item

func (s *Store) Item(id string) (Item, bool, error)

Item reads one item.

func (*Store) Items

func (s *Store) Items() ([]Item, error)

Items lists every item, by id.

func (s *Store) Logo(catalogID string) (data []byte, contentType string, ok bool, err error)

Logo reads a catalogue's mark and the type to serve it as. A catalogue without one is not an error — it is the ordinary case, and the caller falls back.

func (*Store) Picture

func (s *Store) Picture(itemID string) (data []byte, contentType string, ok bool, err error)

Picture reads an item's picture and the type to serve it as. An item without one is not an error — it is the ordinary case, and the caller falls back.

func (*Store) Release

func (s *Store) Release(id string) (Release, bool, error)

Release reads one release.

func (*Store) ReleasesOf

func (s *Store) ReleasesOf(catalogID string) ([]Release, error)

ReleasesOf lists one catalogue's releases, newest first.

func (*Store) SaveCatalog

func (s *Store) SaveCatalog(c Catalog) error

SaveCatalog writes a catalogue, replacing any record with the same id.

func (*Store) SaveItem

func (s *Store) SaveItem(i Item) error

SaveItem writes an item, replacing any record with the same id.

func (s *Store) SaveLogo(catalogID string, data []byte, contentType string) error

SaveLogo writes a catalogue's mark durably, then removes any other format the catalogue had, so switching from PNG to SVG cannot leave a stale file that [Logo] would serve instead.

func (*Store) SavePicture

func (s *Store) SavePicture(itemID string, data []byte, contentType string) error

SavePicture writes an item's picture durably, then removes any other format it had, so replacing a PNG with a JPEG cannot leave a stale file that [Picture] would serve instead.

func (*Store) SaveRelease

func (s *Store) SaveRelease(r Release) error

SaveRelease writes a release. A release is never rewritten in practice — an order names one and its contents must not move — so this is the one call that creates it.

type TargetRef

type TargetRef struct {
	// System is the target system's name in the installation's own vocabulary —
	// "entra", "ad", "jira". Atlas never interprets it; it only requires that an
	// observation and the item it should match agree on the spelling.
	System string `json:"system"`
	// Ref is the right as that system reports it. Compared literally, case and all:
	// a distinguished name differing only in case is two different strings to the
	// directory that issued them, and normalising here would attribute a right to
	// an item on a similarity Atlas invented.
	Ref string `json:"ref"`
}

TargetRef says what this item looks like in one target system: the group, the licence SKU, the role, named exactly as that system names it.

It exists for the commissioning load and for the reconciliation after it (ADR-0333). A target system reports rights in its own vocabulary — "CN=VPN-Users", "ENTERPRISEPACK" — and an entitlement holds a catalogue item id. Something has to join the two, and the join has to be *data a person can read*, because the whole value of a load is a report somebody checks before it is applied. A join buried in a script produces a report that says "Alice holds VPN access" and gives the reader nothing to check it against.

It is deliberately not the same knowledge as the provisioning process, though the two overlap. The process *acts*: it is what adds the membership. This is a *claim* about the target system, and a claim is the thing reconciliation can test. An installation that leaves this empty simply has an item no load will ever attribute — which is the right default, not a degraded one.

type Theme

type Theme struct {
	// Accent is the source brand colour as #rgb or #rrggbb, lower case.
	Accent string `json:"accent,omitempty"`
	// Typeface names one of [Typefaces]. It is a choice from a list rather than a
	// URL on purpose: a web-font URL would reach a third party on every portal
	// page load, carrying the visitor's address there — an outbound dependency on
	// pages that must render when nothing else is reachable, and a data flow an
	// operator in public administration cannot accept on a customer-facing
	// surface.
	Typeface string `json:"typeface,omitempty"`
}

Theme is a catalogue's appearance. An empty field means the instance's own.

func (Theme) Valid

func (t Theme) Valid() error

Valid reports whether this theme can be stored, and says why not.

type TranslationReport

type TranslationReport struct {
	// Checked is how many products were looked at, so a quiet report can be told
	// from an empty one.
	Checked int `json:"checked"`
	// Catalogs are the ids this report covers, sorted: the ones the caller may
	// maintain. A caller who maintains none gets an empty list rather than a 403,
	// because having nothing to maintain is not a refusal.
	Catalogs []string `json:"catalogs"`
	// Gaps is every place a product says something in one declared language and
	// not in another, each naming the catalogue and the product it is about.
	Gaps []Problem `json:"gaps"`
}

TranslationReport is what a maintainer still owes their catalogues.

It exists because a gate was removed. Publishing used to refuse a product named in one declared language and not another; the refusal protected nobody — the portal falls back to the language the catalogue has — but it did make the gap impossible to ignore. Removing it without this would make a half-translated catalogue invisible again, which is the failure the gate was built against, and it would arrive as "the French portal reads oddly" months later.

Read off the catalogues as they stand and NOT off their releases, unlike its two siblings. The fulfilment report reads the newest release because only what is published can park an order; this is a list of work to do, and work to do is about what is being edited. It is computed from the same input a publish is, so what it says is exactly what the next publish would have to live with.

type Unpublished

type Unpublished struct {
	// Released reports whether a release exists, and the two fields after it
	// identify the newest one.
	Released  bool   `json:"released"`
	ReleaseID string `json:"releaseId,omitempty"`
	// ReleasedAt is when it was published, in the server's own seconds.
	ReleasedAt int64 `json:"releasedAt,omitempty"`

	// Added is offered now and absent from the release: publishing puts it in front
	// of people.
	Added []UnpublishedItem `json:"added"`
	// Removed is in the release and no longer offered: **the portal is still
	// offering it**, and publishing is what takes it away. This is the one a
	// maintainer cannot otherwise see, because it is gone from every screen they
	// have and present on the one they do not.
	Removed []UnpublishedItem `json:"removed"`
	// Changed is offered in both and edited since: the portal is showing an older
	// name, price, state or description than the catalogue holds.
	Changed []UnpublishedItem `json:"changed"`
}

Unpublished is the difference between the newest release and the catalogue as it stands.

Empty lists with Released set means the two agree — the portal is serving this catalogue as it is. Released false means nothing has ever been published, which is a different sentence entirely: the portal offers nothing from here at all, and the offering below is what the first release would carry.

func (Unpublished) Any

func (u Unpublished) Any() bool

Any reports whether publishing would change anything for the people ordering.

type UnpublishedItem

type UnpublishedItem struct {
	ID string `json:"id"`
	// Texts is the item's name per language. For a removed product it is the
	// **frozen** copy's, because that is the name on the portal right now, and
	// naming it any other way would describe something the reader cannot see.
	Texts map[string]string `json:"texts,omitempty"`
	// State is the live record's, and empty for a removed product whose record is
	// no longer readable here.
	State State `json:"state,omitempty"`
}

UnpublishedItem names one product in terms a maintainer can act on.

type Variant

type Variant struct {
	ID    string            `json:"id"`
	Texts map[string]string `json:"texts"`
}

Variant is one orderable shape of an item — a laptop's size, a licence tier.

Variants are unordered on purpose. An ordering would let the basket resolve a conflict by picking the higher one, and "higher" is meaningful for a licence tier and meaningless for Windows against Linux. The basket asks the orderer instead.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL