Skip to content

[Development] Discovery Service Setup - #20

Merged
manjudr merged 198 commits into
developmentfrom
feat/18-network-setup
Sep 9, 2026
Merged

[Development] Discovery Service Setup#20
manjudr merged 198 commits into
developmentfrom
feat/18-network-setup

Conversation

@manjudr

@manjudr manjudr commented Sep 7, 2026

Copy link
Copy Markdown
Member

No description provided.

manjudr added 30 commits August 25, 2026 18:16
Store two covers per geometry instead of one — cells_full
(CONTAINMENT_FULL, a guaranteed subset) and cells_cover
(CONTAINMENT_OVERLAPPING, a guaranteed superset). A geometry represented
as a set of cells turns every CQL2 operator into indexed array algebra on
BIGINT[], answerable with no spatial extension. Seven of nine operators
are now answered over all seven RFC 7946 types on both sides of the
comparison; the previous design answered S_DWITHIN for Point only.

S_TOUCHES and S_CROSSES are refused with 400 / SPT_UNSUPPORTED_OPERATOR
rather than deferred. Touching is measure-zero and cells have measure, so
refining the resolution does not converge — listing them as "later" would
promise something no resolution can deliver.

A geometry that cannot be proven to fail is returned, so the result set
is a superset of the exact answer under ANY and a subset under NONE. That
also makes quantifier ALL answerable for the first time, since every
geometry type is now decidable.

Also in this change:

- An offer's geometry attributes to that offer's resourceIds rather than
  to the whole catalog: one resource_geometries row per id, falling back
  to catalog-level when the array is absent or empty, which is the
  meaning offers.resource_ids already carries. `touched` now follows
  offers too, or a patch that moves a shopfront and names no resource
  would re-derive the geometry and write it nowhere.

- JSONPath attribute filtering moves into Phase 1. Both columns and both
  jsonb_path_ops indexes were already landing in Task 14, so it costs a
  parser, a rebase and a WHERE clause, and no migration.

- SEARCH_FAIL_ON_UNAVAILABLE_MODE defaults to false. Phase 1 ships
  EMBEDDING_PROVIDER=noop, so defaulting to refuse would make every
  textSearch a 400 until an Ollama deployment exists.

Open Items is now empty; each decision is recorded with its reasoning.
The Task 19 test pin said an absent discover `networkId` falls back to
APP_NETWORK_ID. Four other places say it searches every network: the
service.Discover pseudocode, the Scope struct commentary, scenario 29,
and the closing decisions table. The pin is the stale one — it predates
the decision — and it is the one that matters, because a test pin is the
sentence that becomes an assertion. Implemented from that bullet alone,
Task 19 would have reintroduced single-network scoping and failed
scenario 29.

Kept rather than deleted: scenario 29 asserts the behaviour end to end,
this pin asserts the mapper emits no network predicate at all, which is
the thing that actually breaks.

Also annotate `networkId` in the discover wire shape. It carried a bare
OPTIONAL while the identical field in publish's identical context block
is annotated "defaults to APP_NETWORK_ID" — an invitation to assume
symmetry, and the likeliest way the wrong bullet got written.
]

Comments, Abstraction, No globals, Errors, Test doubles and TODOs join
Global Constraints, which is the section that says "every task inherits
these." A separate "conventions not yet formalized" note was the other
option and is worse: a section with no claim on any task is a section
nobody enforces.

Five are new statements of what the plan already does. Abstraction is
not — as usually phrased ("no interface until the second implementation
needs it") it would forbid the Embedder, CatalogReplicator and Keyring
seams this plan deliberately ships. So it is stated in the form A6/A7
already uses: a seam ships with a conformance test or a second
implementation behind it. That admits the three named seams, each of
which has a constructing task and an exercising test, and still refuses
the speculative interface the rule is aimed at.

Errors draws the boundary the codebase would otherwise blur: %w-wrapping
is for Go errors, while a validation Fault is a value with its own Path
and Code, aggregated rather than returned, and never wrapped.
Task 1 of docs/design/discover-and-publish.md.

- Module github.com/OpenAgriNet/discovery-service on Go 1.25, directory
  skeleton per the design document's File Structure.
- make build|test|lint|sqlc|migrate, plus security, docker, up/down.
  Test targets pin EMBEDDING_PROVIDER=hashing rather than inheriting the
  production noop default (A5); without the pin the whole semantic path
  goes untested from the day semantic search was deferred.
- Toolchain pinned in a separate tools/ module so the service go.mod
  states only what ships: a licence audit and the T4 supply-chain scan
  cannot tell a linter's transitive dependency from a runtime one once
  the two graphs are merged. golangci-lint, sqlc, govulncheck and
  golang-migrate build into bin/ on demand, so a clean checkout needs
  nothing installed but Go.
- .golangci.yml enables gosec, errcheck, govet, revive and staticcheck,
  with revive configured to enforce the Global Constraints that were
  otherwise review conventions: godoc on exported symbols, 50-line
  functions, control nesting depth 3, early returns.
- Multi-stage Dockerfile, dependency layer first, -trimpath for
  reproducible bytes, distroless static runtime. schemas/, migrations/
  and config/ are copied; beckn.yaml deliberately is not, because it is
  fetched at boot from VALIDATION_SPEC_URL and a baked copy is a second
  source of truth that ages with the image.
- docker-compose pins pgvector 0.8 on PostgreSQL 16 (D6).
- CI runs lint, test and security (govulncheck plus a Trivy image scan
  failing on HIGH/CRITICAL — T4). The scan is of the image rather than
  of go.sum so the pinned toolchain cannot fail a release it never
  ships in.
- ADRs 0001-0015: 0001-0011 are the Technology Decisions table (D1-D11),
  0012 and 0013 come from T5, 0014 is the seam-ships-with-a-test rule
  applied to CatalogReplicator and Keyring (A7), 0015 is the
  master-catalog-and-inheritance rejection (A1).
- MIT LICENSE, matching the rest of the OpenAgriNet org.
- assign memory backend + conformance suite ownership across Tasks 11/12/15/16
- add no-op Trace middleware in Task 8, upgraded by Task 23, fixing the
  ordering bug where Task 20 tested a chain position Task 23 hadn't built yet
- pin stats.itemCount/providerCount/categoryCount assertions in Task 18
- remove stale (PARKED) tags on filter_parser.go/jsonpath.go (Task 22 ships them)
- complete A4/A7 task citations, correct A1's mapper attribution
- add missing tests/testdata/beckn-v2.0.0.yaml to Task 4's Files list
…ng drift

- File Structure tree pointed at the retired documentations/ layout instead
  of docs/adr + docs/design
- filter-subset validation now lives where the doc calls it load-bearing
  (src/platform/jsonpath/), with Task 22's Files list to match; postgres/
  jsonpath.go only executes the already-validated expression
- Trace's Task 8 no-op gets an observable X-Beckn-Trace-Seen marker so
  Task 20's order test can actually assert its position; Task 23 drops it
- Task 18's Tests-pin used defaultDirective; the pseudocode calls it
  applyDirectiveDefaults
- A1 no longer cites Task 17, which never mentions MASTER/extends
- A9 no longer claims all five defaulted fields are mapper-resolved --
  catalogType/updateMode/visibleTo are directive fields resolved in
  publishOne before the mapper runs
…der pin [#11]

sqlc.narg('spatial_op') was declared @spatial_op at two of its three uses
in the discover EXISTS block — the exact mistake the paragraph below it
calls fatal, since sqlc generates from whichever declaration it reads and
the first line uses NULL as the no-spatial-constraint sentinel.

The catalog-geometry insert carried a comment saying there was no
ContainmentFull cover to write; cells_full is that cover. The mode with
no column is ContainmentCenter.

Task 17's Produces named ExtractCatalogGeometries(catalogIndex, provider),
the pre-walker signature, three lines above prose saying it takes the
merged catalog. Now ExtractGeometries(catalogIndex, merged), spelled the
same way at its definition and its call site.

Task 20's middleware-order assertion could not fail: Trace stamped its
marker before calling next, so it survived a recovered panic under either
nesting. Trace and Recover now both append to one ordered X-Beckn-Chain
header, and the test reads the order of the two entries rather than the
presence of one.
The plan is the contract; this is how it gets executed — one task per
session, with a review gate between tasks. It was sitting untracked, so a
clone had the plan and no way to drive it.
A Point and a LineString contain no H3 cell, so their cells_full is the
empty array — and '{}' <@ anything is TRUE in PostgreSQL while
'{}' && anything is FALSE. Three operator branches were phrased over
cells_full on the degenerate side and therefore stopped being predicates:
S_WITHIN and S_CONTAINS fell back to the bounding box, and S_DISJOINT,
which skips the box by design, matched every Point in the corpus. Since a
shopfront is a Point, that is the commonest row in the table.

Refute with cover, prove with full. A ⊆ Q implies cover(A) ⊆ cover(Q), so
NOT (A.cover <@ Q.cover) refutes S_WITHIN soundly and strictly more
tightly than the full form it replaces; S_CONTAINS is the same statement
with the arguments swapped. S_DISJOINT gains two refutations by the same
reasoning: A.cover <@ Q.full means A ⊆ Q, and a non-empty A inside Q
meets it. Scenario 30 could not have caught any of this — Polygon vs
Polygon is exactly the case where both full covers are non-empty.

Also in this change:

- `touched` now unions an offer's resourceIds from BEFORE and AFTER the
  merge. Relocating an offer from r1 to r2 touched only r2, so r1's
  geometry row was never deleted and a spatial search on the offer path
  kept returning a resource that offer no longer covered — for ever.

- Three CHECK constraints on resource_geometries, on the argument the
  CHECK (id <> '') block already makes. cells_cover being non-empty is
  load-bearing now rather than tidy: it is what the cover <@ cover
  refutations rest on.

- plan_cache_mode = force_custom_plan on the pool. Every nullable
  predicate in discover.sql is index-usable only when the planner can
  fold the IS NULL arm away, which a generic plan cannot — so the fast
  plan is the one a cold connection gets and the slow one is what a warm
  connection settles into. EXPLAIN assertions now run six times first.

- A redundant sargable = ANY beside the schema EXISTS, so
  idx_resources_schema can drive the scan instead of filtering after it.

- IS DISTINCT FROM on the gate propagate: the rule is that every resource
  ENDS the transaction carrying the catalog's gate, not that every row is
  rewritten into a fastupdate = off GIN index on every publish.

- idx_resources_catalog_id and idx_offers_catalog_id dropped; both tables
  are keyed (catalog_id, id) and the primary key's btree already leads
  with catalog_id.

- source_path wildcards the catalog's own index, which is a property of
  the request rather than of the catalog. Fault paths keep the concrete
  one. target_path = wildcard(source_path) is now an asserted invariant.

- The same catalog id twice in one request is REJECTED rather than
  silently last-write-wins across two transactions.

- geo_haversine_m is expected NOT to inline and now says so: least() is
  non-strict, and STRICT is what stops a NULL coordinate returning half
  the Earth's circumference. Asserted with EXPLAIN (VERBOSE).

Scenarios 6a, 10a, 30a and a third leg on 35 pin the four defects; Task
12 gains a non-empty-cover property test and its Go twin of 30a.
Config, Load and Defaults over the T1 layering: envDefault tags, then
config/common.yaml, then the optional config/instance.yaml, then the process
environment on top so a secret store beats a checked-out path (TRD §8).

One env.Parse rather than one per layer: env.Parse applies every envDefault
tag whose variable is absent, so a second pass over an already-populated
struct resets each field the environment does not name. The YAML layers are
folded in as environment entries instead, which keeps the tags as the floor and
leaves precedence to map insertion order — and gives the unknown-key refusal a
place to live, since the overlay walks each document against the struct.

Search carries three separate bounds under names that say what each one
bounds: DefaultPageSize and MaxPageSize clamp a page, MaxCandidatesPerMode
bounds one retrieval mode's candidate list and is also the reachable
pagination depth. Database.MaxConns defaults to 32 from the concurrency model
(A2), not from pgxpool's max(4, numCPU), under which the performance
scenario's sixteen concurrent discovers would measure the pool queue.

validate reports every problem in one boot rather than one per restart.
… below [#9]

maps.Copy put every environment entry into the override map, including the
present-but-blank ones. env/v11 then reads a blank entry two different ways:
with an envDefault tag it returns the tag value, without one it sets "". So
`SEARCH_MAX_PAGE_SIZE=""` over `search.maxPageSize: 40` in instance.yaml
resolved to 100 — not the file's answer, not the operator's, but the struct
tag's, silently. `value: ""` in a pod spec and a blank key arriving through
envFrom are the ordinary shapes this arrives in, and instance.yaml is the layer
an operator is most likely to have set, so this defeated the precedence the
package exists to establish exactly where it was least visible.

Skipping blanks makes the rule uniform: a blank variable is not a value at any
layer, so the layer below stands. That also settles the five fields with no
envDefault (App.Network, Database.URL, Validation.SpecURL, OTel.Endpoint,
Replication.Targets), where a blank used to win and set the field empty — one
spelling meaning "clear this" for those five and "restore the default" for the
other twenty, with nothing on the operator's side to distinguish them.
Precedence is a property of the layer, not of whether a field happens to carry
a tag. A deployment that wants a value cleared leaves it out of the file, or
gets an explicit knob; it does not overload a blank string.

The seventh precedence pin covers both halves — a field with a tag and a field
without — and fails on the previous commit with the reported values.
Task 2's Groups list was the only place config groups are defined, and it did
not name the three knobs the plan states elsewhere as configuration:
ERROR_INCLUDE_LEGACY_TYPE (C1, line 84 and Task 5), EXT_ALLOW_NETWORK_FETCH
(Task 10's SSRF boundary) and ResolutionCells (the Constants table, marked
"config, not a constant"). As it stood, Tasks 5, 10 and 12 each had to reopen
config.go and invent a home with nothing in the plan sanctioning it. The doc is
amended first so the groups exist in the plan before they exist in the struct.

Group names: Errors, Ext and Geo, matching the prefixes the plan already fixed
for the two variables it names (ERROR_, EXT_) and reading GEO_RESOLUTION_CELLS
for the third, which had no fixed name. Each is its own group rather than a
field on an existing one:

- Ext.AllowNetworkFetch, not Validation.AllowNetworkFetch. Validation already
  holds SpecURL, and an AllowNetworkFetch beside it reads as though it gated the
  L1 spec fetch — which always happens. What it forbids is dereferencing a
  @context URL out of a request body. Separating the groups removes the misread
  without inventing a longer name than the variable it maps to.
- Errors is the error *body*, not error handling; nothing else in Config shapes
  a response body, so there was no existing group it belonged in.
- Geo will hold whatever else the spatial path makes configurable. The other
  three entries in the Constants table stay constants.

A one-field group is already this file's shape (Log, Auth), so none of these is
a new kind of thing.

validateGeo rejects a resolution outside H3's 0 through 15. Without it an r16
fails inside the first cover a publish or discover builds, which is a request
error for a startup mistake; the boot is the only place that reads as an
operator's problem.

Neither false is written into common.yaml. Both are struct-tag defaults with
the same value, and a second copy is a second place to change; common.yaml
carries a value only where the project chose one against a different tag.
…er below [#9]

828abdb made a blank environment variable a no-op. One layer down the opposite
still held: a blank value in common.yaml or instance.yaml overwrote whatever the
layer below set, and env.Parse then ignored it — so the envDefault tag came back
where a field had one and the zero value stood where it did not. `log.level: ""`
over a reviewed `warn` produced `info`, and `validation.specURL: ""` cleared it.
One operator gesture with two opposite outcomes is the ambiguity the unknown-key
refusal exists to prevent, and it was living inside the loader that enforces it.

A blank in a file is a deliberate keystroke, so scalar refuses it the way it
already refuses a nil value, rather than skipping it as the environment layer
does. Nothing needs an empty string from YAML: the two fields where empty is
meaningful (Database.URL, App.Network) are required non-empty by validate, and
for the rest omitting the key already means "take the layer below" — which is
the only spelling the refusal costs.

An empty sequence is refused on the same terms. It renders to the same empty
string, and its effect today is exactly omitting the key (env skips set on a
blank, env.go:507, so Targets stays nil rather than becoming [""]), so it buys
one rule instead of two and removes the trap that a slice field with an
envDefault would otherwise inherit.

Task 2's layering description now states the rule for both halves at once, since
after this there is only one rule, and its Tests pin line names the new check.
0e28756 refused an empty sequence on the same terms as a blank string, which
closed the last spelling any layer had for clearing a value a lower one set. With
common.yaml naming a target, `targets: []` and `targets: ""` in instance.yaml
both failed startup and a blank REPLICATION_TARGETS was ignored — so a deployment
that replicates to nothing had no way to say so. Replication.Targets is the field
where that matters, because empty there is a value rather than an absence: it is
how the plan selects the no-op replicator.

`key: ""` keeps the refusal. It reads as both "no value" and "the empty string",
and the ambiguity is the whole reason a blank is not a value. `[]` has one
meaning in YAML and an operator who writes it means it, so the rule is about
blanks and an explicit empty collection is not one.

Clearing works because env.Parse never sets a blank (env.go:507), leaving the
field zero. That holds only where no envDefault comes back in its place, so a
slice field must not carry one — asserted over the struct by reflection rather
than left as a comment, since the trap would otherwise arrive silently with the
first slice field that wants a default.
440bcf0 decided the empty sequence by the value's shape rather than the field's,
which let it through on a scalar field: `log.level: []` rendered blank, and a
blank on a field with an envDefault comes back as the tag — so it erased a
reviewed `warn` and produced `info`, which is the failure 0e28756 closed for
`key: ""`, arriving under a different spelling. Nested, `[remote-a, []]` became
an empty element inside the slice, junk that reaches the field while the same
position spelled `""` was correctly refused.

collectField is the only place that knows the field's type, so the decision moves
there: a list on a field that is not a slice fails startup whether it is empty or
populated — populated, it was being joined into a string the field never asked
for — and an empty one is answered there. scalar refuses an empty rendering again,
which is now only reachable nested, where empty is never a value.

The clearing spelling is unchanged and still pinned: instance.yaml's `targets: []`
clears the two targets common.yaml names, and leaves every other key standing.
Each task is driven by a fresh session, which is what keeps the plan honest — a
task section that a new reader cannot execute has a gap, and finding that on task
3 is cheaper than on task 19. The cost is that every driver prompt re-types the
same standing rules, and a rule that lives only in a prompt is one nobody can
review.

What goes here is what is not already written down: the working agreement, the
commit and issue convention, why make test pins the embedding provider, and the
handful of rules that generalised out of task 2 — that a name the plan chose is
load-bearing, and that a pin holding only because someone remembered it is not a
pin.

What does not go here is the Global Constraints table. It is binding and it is in
the plan; a second copy would be a second thing to keep true, and by the doc's own
Comments rule it is the copy that rots.
Task 3. logger.New builds the production JSON logger, NewContext installs it
for a request, With adds fields to what is already there, and FromContext
reads it back — returning a no-op logger rather than nil, so no call site has
to ask whether the middleware that installs one ran.

New takes config.Log, not config.Config. The narrow parameter states what the
logger reads, which is the level and nothing else; and Config.Database.URL
carries a password, so passing the whole struct would put a secret inside the
one component whose job is to write things down. Later tasks inherit the
narrow form, which is the point: a component that cannot reach a secret cannot
log one.

Two decisions the doc does not spell out:

Production sampling is off. zap's production sampler keys on level and message
only and drops all but the first hundred entries sharing a pair each second.
Task 8's RequestLogger writes one completion line per request under a single
message, differing only in its fields, so leaving sampling on would discard
most of the request log at exactly the load worth reading it at.

The four field names Task 3 names — request_id, transaction_id, message_id,
action — ship as typed constructors here rather than as string literals at
each call site. One key spelled two ways is two fields to whatever queries the
logs, and the mistake stays invisible until someone searches for the spelling
that is missing. Typed constructors are also what the Logging constraint asks
for in place of zap.Any.
e4b2145 stated the "no Sugar() on the request path" rule as an existing
.golangci.yml entry. Task 3 is the task that adds it, so a fresh agent could have
read that line as licence to skip the very thing the rule around it demands —
which is exactly the failure the bullet warns about. It now says what the plan's
"asserted by make lint" phrasing is asking for.

Also: the storage row was garbled mid-sentence, and tests/ was missing from the
layout, which would send a new reader looking for a home that already exists.
Task 3's pin is "no Sugar() on the request path (a lint rule, asserted by make
lint)". Avoiding Sugar() by hand is not a pin: it holds only as long as
everyone who touches the request path remembers it, and it fails silently the
first time someone does not. forbidigo makes both spellings a build failure.

Both forms are forbidden because both take any. Sugar()'s loosely typed API
and zap.Any are the same hole: the field names stop being greppable, and
whatever a value's %v prints goes to the log — which is how a connection
string with a password in it reaches a line nobody audited.

No carve-out for _test.go, unlike the gosec and function-length exclusions
above it. The rule is about the request path, but a helper a test shares with
the request path is on the request path, and the cost of the strict form is one
nolint with a reason on a startup line that genuinely wants Sugar.

Verified by running it against a deliberate violation before and after: the
chained form, the stored form, and zap.Any all fire and make lint exits
non-zero; 0 issues with the violation removed.
5a46643 forbids `.Sugar` and `zap.Any`, but the same hole is reachable through
zap's package-level singletons, and `zap.S().Infow("boot", "dsn", dsn)` passed
`make lint` with 0 issues — the connection-string-in-a-log-line failure the rule
exists to prevent, arriving by the one door left open.

It is the likeliest door, too. You reach for zap.S() exactly when you do not
have a logger in hand, which is the position of anyone who was not passed the
context one; someone who already holds a *zap.Logger has no reason to sugar it.
They are package-level singletons besides, so the No globals constraint forbids
them independently of anything about typed fields.

Matched on source text rather than resolved types, and the comment now says why:
analyze-types would close the aliased-import spelling (import z "go.uber.org/zap";
z.Any) but resolves a chained call to the method at its end, so zap.S().Infow
becomes SugaredLogger.Infow and matches nothing. The alias is a spelling nobody
reaches for by accident; the chain is the one everybody writes.

Verified against a deliberate violation: no forbidigo issue before, both forms
reported and exit 1 after, 0 issues on the tree.
Produces is what the next task's implementer reads to learn a signature they
cannot see, so a wrong one is worse than a missing one. It said `logger.New(cfg)`
where New takes cfg.Log, and omitted NewContext entirely — the function the
request middleware in a later task has to call to install the logger at all.

Records why New narrows its argument: Database.URL carries a password, and the
component whose job is writing things down should not be handed it. Also records
that LOG_LEVEL is deliberately not in validate — the level table belongs to zap,
and a copy in config is a second one to keep true — so the gap is not closed
later with a list that drifts.

Tests pin now names what the tests actually hold, including sampling being off,
and the lint rule covers zap.S and zap.L alongside Sugar and zap.Any.
sha256 22ec5593dad45f54bbd803c4179cd5c2f0154cc5055bcaf9a23de53f43638932,
copied verbatim from the protocol-specifications-v2 repo at api/v2.0.0.

This is not the spec the service validates against. beckn.yaml is fetched at
boot from VALIDATION_SPEC_URL precisely so a spec bump does not require a
rebuild, and baking a second copy into the image would give the running process
two sources of truth that drift apart silently.

A committed copy under tests/ is a different object with a different job: it is
the version this build's wire structs were written against, so the conformance
walk can answer "have the structs and the spec parted company" at CI time
rather than at the first consumer strict enough to check. Bumping it is meant
to be a reviewed diff — that is the whole value of pinning the hash.

Committed separately from the code it pins because it is 5k lines of vendored
third-party YAML, and mixing it into the same diff as the structs would bury
them.
The shapes that cross the network, plus the one place a request body is read.

Three rules decide how a schema property becomes a Go field, and each exists to
keep a later task honest rather than to look tidy here:

  - No declared default means a pointer or a json.RawMessage. MERGE is RFC 7396
    (A8) and encoding/json is the last thing in the chain that still knows the
    difference between a key the publisher omitted and one they set to null.
    Flatten it here and the merge cannot tell "keep what is stored" from
    "delete it" — which is why TimePeriod's four members are raw rather than
    *string: a patch that clears endDate and keeps startDate needs three states
    per member, and *string collapses two of them into nil.
  - A field stored verbatim stays a json.RawMessage. provider, descriptor and
    resourceAttributes land in JSONB and are rendered back unchanged, so parsing
    them would only produce a second, lossier copy of a document this package
    does not interpret.
  - A Go type never narrows the schema. timestamp stays a string so a malformed
    one comes back as a CTX_ fault carrying $.context.timestamp, instead of
    failing json.Unmarshal and telling the caller only that the body was
    unreadable.

Targets resolves beckn.yaml's oneOf once, in UnmarshalJSON, so everything
downstream sees a slice. It refuses every other shape rather than reading it as
empty: an empty targets would drop the spatial predicate and answer with the
whole index, and a silently widened result is the failure the spatial path is
built to avoid on every branch.

ParseEnvelope deliberately does not call DisallowUnknownFields. The spec permits
unknown keys, this service is one hop in a chain it does not own, and a v2.0.x
sender carrying a key this build has never heard of must reach the L1 validator
— which knows which schemas close additionalProperties — rather than being
turned away by a decoder that knows only this build's structs. It does use a
Decoder and More() rather than json.Unmarshal, because Unmarshal accepts a
leading value and ignores whatever follows: two concatenated documents would
parse as the first one, and reading half a request nobody meant to send is
worse than refusing all of it.

OnDiscoverAction is exactly {Catalogs []Catalog} and must stay that way. The
schema is additionalProperties:false with catalogs as its only property, so a
Degraded field would not be an extension but a response that fails its own
schema; omitempty would hide that on the ordinary path and ship the invalid body
on precisely the degraded path that matters. Degradation travels as the
X-Beckn-Degraded header (C11), and the conformance walk now catches the field if
anyone adds it.

The walk compares every wire struct's JSON tags against its schema node in both
directions — an extra tag ships a key the schema forbids, a missing one drops a
publisher's data in transit. Its allowlist holds one entry: Error.type, the C1
legacy error category, written only when ERROR_INCLUDE_LEGACY_TYPE is true.

C4 and C5 are documented conflicts but are not allowlist entries, because the
plan resolved both by following the schema — C4 keeps the JSON-LD pair scalar
against a reference implementation that accepts arrays, C5 drops category
against a PRD that assumed one. Neither produces a property-name mismatch, so
each is pinned by an assertion that actually holds: that Attributes still types
both members as required scalar strings, and that no schema in the document
declares a category property. Entries that match nothing would have been worse
than useless — the walk checks its allowlist in both directions and would have
failed on them.

TestEveryWireStructIsBound parses the package source, because Go cannot
enumerate a package's types at run time, and fails on an exported struct with no
binding. Without it the walk keeps passing while the drift it exists to catch
goes in beside it.
Both parsers claimed to refuse every shape they do not understand, and both
had the same hole: encoding/json hands `null` to an UnmarshalJSON method like
any other value, and unmarshalling it into a string or decoding it into a
struct succeeds as a no-op rather than failing. So `null` was the one input
that got past a guard written to be total, silently.

Targets read `targets: null` as Targets{""} — one empty JSONPath pointer that
no sender wrote. That is precisely the outcome the type exists to prevent: its
whole argument is that a shape the oneOf does not admit must be refused here
rather than left for a downstream component to notice, because a spatial
predicate that quietly resolves to nothing answers with the whole index. The
array arm had the same hole one level down, so it now reads []*string and
refuses a null element: ["$.a", null] is not an array of strings, and []string
renders that null as "" with nothing to show for it. L1 validation would catch
both while VALIDATION_ENABLE_L1_SCHEMA is on, which is exactly the dependency
the type was written not to have.

ParseEnvelope returned a zero envelope and a nil error for a body of `null`.
Every other non-object — an array, a number, a bare string — fails the decode
with a type error; null does not, so the one body that could not be read at all
would have been answered as a context fault over missing fields instead of as
the transport failure the plan reserves a NACK for. The classification is the
point: a caller who sent an unreadable body and a caller who sent a readable
body with a bad context need different answers.

Neither shape satisfies the schema — `targets` is a oneOf over a string and an
array, and an envelope is an object — so refusing them narrows nothing a
conformant sender can send.
Task 5 was written assuming `SPT_` and `DOM_` are code families the spec
admits. Checked against the fixture Task 4 pinned, neither is: `ErrorCode` has
76 members across exactly CTX_, NET_, AUT_, SCH_, POL_ and BIZ_. `SPT_` is
absent from the document entirely; `DOM_` survives only in the prose of
`Error.code`'s own description, with no enum members behind it. Six codes the
plan spends are not in the enum.

What makes this worth writing down rather than fixing in passing is that
nothing fails when it is wrong. `Error.code` is declared `type: string`, not
`$ref: ErrorCode`, so the "MUST be a value from the ErrorCode enum" is prose:
L1 validation will not reject an invented code, and Task 4's conformance walk
compares property names, not values. An agent implementing Task 5 would write
the constructors the section names, watch every test go green, and ship codes
no conformant consumer can interpret.

So the section now asks for the choice to be made — map the six onto existing
members, or state that this service treats `code` as open and quote the prose
it departs from — and says that the Produces line and the Tests pin move with
whichever is chosen. The checklist entry repeats it, because that table is
what a fresh session reads before opening the task.

Also adds Carried chores for the four plan-design commits missing their [#11]
tag. They live outside the task list because they are not blocked on any task
and are already on origin, so clearing them needs a force-with-lease that is
the human's call — and a chore with no home is a chore that gets rediscovered
by the next person to audit the log.
Azure, Google Artifact Registry and Docker Hub were carried as `*_ENABLED`
flags reading repo variables. The repo has no variables set — checked — so all
three resolved to false and every `secrets.*` behind them to an empty string.
The verified release (tag v0.0.1-rc2) pushed to GHCR and nothing else, which is
what the switchboard was always going to do.

Removed, rather than left off, because an unconfigured registry is not
flexibility. It is a fourth way for a release to look green while publishing
nothing: turn a flag on without its namespace secret and the path collapses to
a bare registry and a slash. Fewer states that can be wrong beats more states
nobody has exercised. Adding one back is one entry in IMAGE_REPOS and one login
step.

GHCR is now unconditional rather than a flag defaulting true, so IMAGE_REPOS
cannot be empty and require-image-repos has one thing left to check: OWNER,
which CI always sets and a laptop never does. That is now what it says.

id-token: write goes with them — it existed only so google-github-actions/auth
could exchange an OIDC token for Google credentials. Nothing left in either job
requests one.

Not touched: the dhi.io login. It reads DOCKERHUB_USERNAME/DOCKERHUB_TOKEN, but
those are the Docker Hardened Images subscription the Dockerfile's base images
need to pull at all, not a Docker Hub publish target. Deleting it would break
every image build.

ci-release.yml: 214 lines to 133.
main moves solely by merging a PR, and GitHub verifies that PR at its merge
commit — the exact tree the merge produces. Re-running build, lint, test and
both scans on the push that lands it re-asks a question answered minutes
earlier, on the same content, and posts a fifth check nobody reads.

Dropping the push trigger makes three expressions unreachable, so they go too
rather than sit there implying an event that can no longer arrive:

  - BASE_REF's `github.event.before` arm, now just the PR's base ref
  - SCAN_IMAGE's `github.event_name != 'pull_request'` arm
  - `if: github.event_name == 'pull_request'` on four comment steps
  - cancel-in-progress' conditional, which existed only to keep main's runs
    from cancelling each other

What this gives up: a merge queue or a direct push to main would land
unverified. Neither is in use — main has no branch protection today — and when
protection is added, requiring the two PR checks is what enforces it.
Two comments explained why the jq program and the trivy cache were extracted by
naming the files they were extracted from — security.yml and build-and-push.yml,
neither of which exists any more. The reasoning is still worth keeping; the
filenames now send a reader grepping for something that is not there.

Reworded to describe the shape of the problem instead of the path.
…ed [#9]

VERSION came from `git describe --tags`. On a commit carrying more than one
tag, describe returns whichever was *created* last, which is not necessarily
the one whose push started the run:

    git tag -a v1.0.0 -m release; sleep 1; git tag -a v1.0.1-rc1 -m rc
    git describe --tags   ->   v1.0.1-rc1

So pushing v1.0.0 would publish ghcr.io/...:v1.0.1-rc1, and because that name
contains a hyphen the :latest rule would read it as a pre-release and leave
:latest pointing at the previous version. A green release, shipped under the
wrong name, with the tag users actually pull never updated. Two tags on one
commit is not exotic — promoting an rc to a release does exactly that.

ci-release.yml now sets VERSION from github.ref_name, the triggering ref
itself, and the Makefile's `?=` lets it win. Nothing is re-derived.

The old comment argued *for* describe on the grounds that one answer on a
runner and on a workstation is simpler. That was the error: the two contexts
do not hold the same information. CI knows the triggering ref exactly; a
workstation has no triggering ref at all, so describe stays the local fallback
and still renders an untagged commit as v0.0.1-rc3-1-gabc1234 rather than a
branch name that would then be pushed as an image tag.

Encoded rather than remembered, per the standing rule: require-image-repos now
rejects a VERSION that is empty or contains a slash or a space. That is what a
non-tag ref reaching a release target looks like — a pull_request would give
"15/merge" — so the guard fails loudly instead of pushing a malformed ref.
Both repos post a coverage comment and a Trivy comment, but they disagreed on
two things a reader would notice putting the two PRs side by side. Aligning on
network-adapter#20, since it is the one already merged.

Severity: HIGH,CRITICAL -> CRITICAL,HIGH,MEDIUM,LOW. Measured before changing
it — the dependency scan reports the same single finding either way, so this
costs nothing today and means a MEDIUM that appears next month is seen here as
soon as it is seen there. The image scan cannot be measured locally (dhi.io
needs a subscription login), so CI is what will report its wider count.

Coverage states: "✅ Passed — not applicable, no changed Go files" was a
contradiction. Passed says something was measured and cleared the bar; nothing
was measured. There is now a distinct "➖ Not applicable" for both paths that
have nothing to measure — no non-test Go files in the diff, and changed files
carrying no coverable statements — matching the sibling repo's wording. Still
exit 0, so the gate is unchanged; only the claim is honest now.
cover-diff summed the profile per LINE. Under -coverpkg every source block
appears once per test binary that was built — measured here, 2294 blocks with
21 copies and 156 with 22 — so the denominator was multiplied by the number of
binaries while the numerator was credited only for the binaries that reached
the block.

The result was not a rounding error. src/publish/controller.go read 7% against
a real 81%, src/discover/controller.go 8% against 89%, and an aggregate of 11%
where `go tool cover -func` says 88.5%. With MIN_COVERAGE=80 that is a gate
that fails EVERY pull request touching a .go file regardless of how well tested
it is — which is a gate people learn to bypass, and worse than no gate.

It was latent on the branch that introduced it, because that branch changes no
Go files and cover-diff exits early on an empty changed set. It would have
fired on the first functional PR after the merge.

tools/cover-report.awk already documents this exact trap and already handles
it: key on the block, keep its statement count once, count it covered if ANY
binary hit it. cover-diff is now the same, and its TOTAL agrees with
`go tool cover -func` — which is the invariant the new test pins.

Moved out of the Makefile recipe into tools/cover-diff.awk for the reason
cover-report.awk lives in a file: make eats `$`, so every `$1` has to be
written `$$1` and a mis-escaped field reads as the empty string rather than
failing. Being a file is also the only way it can be tested, which is the
second half of the fix — this is gate logic, and nothing exercised its
arithmetic.

Writing the test surfaced a second defect: a changed file whose blocks carry
no statements divided by zero and aborted awk with exit 2, before the T == 0
check could report EMPTY. An unmeasurable file crashed the gate instead of
skipping it. Guarded.
v0.2.0 -> v0.3.0, which is the fixed version Trivy names. Reached only through
testcontainers, so it is never linked into the shipped binary and the image
scan was already clean — but the dependency scan is there precisely to catch
what the image scan structurally cannot, and leaving it red trains people to
read a red Security Scan as normal.

Pulls klauspost/compress 1.18.6 -> 1.18.7 and moby/sys/user 0.4.0 -> 0.4.1 as
part of the same module graph resolution.

`make trivy-deps` now reports 0 findings at CRITICAL,HIGH,MEDIUM,LOW.
A `${{ }}` expression is substituted into the run block's TEXT before bash
parses it, so the token was part of the program rather than an input to it — a
value carrying a quote or a backtick would be parsed as shell. Through env: it
is only ever data.

No behaviour change for any well-formed token; this is about the one that
isn't.
The test shelled out to `awk -f tools/cover-diff.awk`. awk is a subprocess, so
a file only it opens is invisible to the go test cache: edit the awk, rerun,
and `go test` replays a stale "ok (cached)" because no Go input changed.

Demonstrated — replacing the whole program with `END{print "TOTAL\t0"}` and
rerunning without -count=1 still reported ok (cached). A regression test that
goes quiet exactly when the thing it guards is being edited is worse than none,
because it reads as a pass.

Now the test reads the program with os.ReadFile and runs the copy. The read is
an input the cache records, so the same sabotage fails immediately with no
-count=1 needed. Verified both directions.
chore: collapse CI to four checks and move the logic into the Makefile [#9]
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

🛡️ Trivy security scan (CRITICAL,HIGH,MEDIUM,LOW)

View full run

Go dependencies

No findings at CRITICAL,HIGH,MEDIUM,LOW.

Container image

No findings at CRITICAL,HIGH,MEDIUM,LOW.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

📊 Test Coverage: ✅ Passed — 95% of changed lines covered, min 80%

nisargabd and others added 12 commits September 8, 2026 11:30
…/6-discover-service-full-coverage

# Conflicts:
#	src/discover/intent_mapper_test.go
#	src/discover/service_test.go
The merge from feat/18-network-setup brought in the rule that an intent
naming no retrieval criterion is refused (SCH_INVALID_FORMAT). Two
pre-existing tests here passed a bare beckn.Intent{} to isolate the
offset/page-depth boundary, which the new rule now refuses before the
boundary logic ever runs. Give both a TextSearch so they test only the
thing they name.
…ig assumptions [#6]

Review on PR #18 found make test hanging for 10 minutes on
TestRunReturnsAListenFailureRatherThanPanicking: it held a loopback-only
address while Run binds the wildcard, so the collision it expects never
happens and serve blocks on an uncancelled context.Background(). Fixes:

- listenLocal now binds the wildcard too, so it holds the exact address
  Run asks for; TestRunReturnsAListenFailureRatherThanPanicking gets a
  bounded context so a future miss on this premise fails in seconds
  instead of hanging the package again.
- The renamed-to-match-behaviour Run test (was ...ServesUntilCancelled,
  which a Port=0 config that's never dialed cannot claim) polls Run's own
  "listening" log line instead of a fixed sleep, removing the flake risk
  on a loaded CI box.
- container_test.go's buildableConfig hardcodes 17 values with no tie back
  to config/common.yaml; a new TestBuildableConfigMatchesCommonYAML parses
  the real file and fails loudly if the subset it commits to (timezone,
  page/candidate bounds, embeddings provider, L1 schema flag) ever drifts.
  Loading common.yaml directly via config.Load isn't an option here: it
  resolves paths relative to the process working directory, which a go
  test binary run from its package directory does not share with a
  deployed container.
- tests/architecture/boundary_test.go gained a third import-graph ban:
  only src/storage/postgres, src/app, tests/acceptance and tests/dbtest
  itself may import tests/dbtest. container_test.go's dbtest.DSN(t) made
  src/app the first package outside that set to require Docker on a plain
  `go test`, and nothing marks a test as integration in this repo (no
  build tag, no naming convention) — this makes the boundary an assertion
  instead of something only found by reading imports.
- The two embedder-selector tests were four/two if-statements pretending
  to be one test each; split into named t.Run cases so a failure names
  which provider broke instead of requiring a line-number lookup.
…s tests [#6]

Review on PR #18 found the same gen.DBTX-failure wrapper written twice
(dbtxFailsAt in catalog_repository_internal_test.go, queryFailsAfter in
read_path_test.go — different files, and package postgres vs the external
package postgres_test, which is why it drifted into two copies). Exported
the former as DBTXFailsAt and deleted the latter; both files now share one
wrapper, one ErrBoom, and read_path_test.go drops the pgx and gen imports
it no longer needs.

Also, all in src/storage/postgres:

- t.Run around every bare table loop that drives a real Postgres query
  (write()'s 11/13-statement ordinals, GetCatalog's four reads, Hydrate's
  four queries): a t.Fatalf on a rollback failure mid-loop was calling
  runtime.Goexit on the parent goroutine and silently abandoning the
  remaining ordinals rather than reporting them unverified.
- wantsay -> want across every case table it appeared in; not a word.
- seedCatalog rebuilds republishPatch(id) instead of restating an
  identical domain.CatalogPatch literal that could drift from it.
- read_path_test.go's bare 768 (20 sites) is now columnDimensions; the
  wrongWidth test double's claims field is declaredDimensions, since
  "claims" alone read as a verb.
- migrate_test.go: a new test had been spliced between an existing doc
  comment and the function it belonged to, leaving both comments
  describing the wrong test. Re-split them and noted why the failure test
  dials loopback directly instead of going through skipIfShort.
- hydrator_internal_test.go's "Hydrate's own four queries above" pointed
  at a different file (read_path_test.go) and a different package
  (postgres_test) — named both explicitly.
…st [#6]

TestValidateConstraintReportsEveryFaultNotJustTheFirst checked len(fatal)
!= 4, which passes for four faults of any kind — not the behaviour the
name claims (one per bad field, not just the first). Now checks the
actual code and path each of the four faults carries.

TestTwoDegradedModesAreBothNamedInTheHeader never built a response or
read a header; it asserted on Discover's own return slice. Renamed to
TestTwoDegradedModesAreBothReturned and pointed its comment at
TestTheDegradedHeaderJoinsMultipleModesWithACommaAndNoSpace
(controller_test.go), which is what actually pins the header string and
its separator.
publish/text_test.go had the same orphaned-comment mistake fixed in
postgres/migrate_test.go earlier in this PR: a new test was spliced
between an existing doc comment and the function it documented, leaving
both comments attached to the wrong test. Re-split them.

src/indexing/geo/h3_test.go's depth-limit test built its nesting with a
bare `for range 9`, unconnected to maxCollectionDepth = 8 despite the
comment's own claim that "the limit itself is what the test names" — the
test would still pass if the constant changed. h3_test.go is package
geo_test and can't see the unexported constant directly, so
h3_internal_test.go now mirrors it as the exported
MaxCollectionDepthForTests, and the test loops geo.MaxCollectionDepthForTests + 1.

Also named the two literals in ringsFor(1000, 99) (anyDistanceMeters,
pastH3MaxRes15) — they read as unexplained magic numbers otherwise.
…native [#6]

TestLoadReadsPathsRelativeToTheProcessWorkingDirectory only checked that
Load fails outside a directory with config/common.yaml beneath it — which
a Load resolving paths relative to the compiled binary would also do, so
it never distinguished the claim in its own name from that alternative.
Added the complement: t.Chdir into the repo root (where common.yaml
genuinely is) and assert Load succeeds, which fails if resolution is ever
changed to be binary-relative.
TestListCatalogResources was the one bare, method-named test in a repo of
sentence-style claims, and it checked two unrelated things (an unknown
catalog, then a published one) under one name. Split into
TestListingResourcesOfAnUnknownCatalogIsErrCatalogNotFound and
TestListingResourcesReturnsThePublishedOnes.

domain/gate_test.go was package domain_test while its siblings
(catalog_test.go, mergepatch_test.go) are package domain, for no reason
tied to what it tests — everything it calls (ScopeGate, Resource,
PruneOfferReferences, Faults) is exported. Moved it to package domain for
consistency, and wrapped TestScopeGateMatchesOnClockBoundsBothPresentBothAbsentAndOneEach's
case table in t.Run so a failure is selectable and reports the case name.
…copy [#6]

spec_index_test.go held a 19-line, whitespace-exact copy of /discover's
requestBody block from tests/testdata/beckn-v2.0.0.yaml, mutated by
strings.Replace in two tests. It matched today, but any reindentation of
the real fixture would desync the copy without either test naming that as
the cause — the failure would surface as a spec-compile error inside the
validator instead. discoverPathBlock now slices /discover's own path item
out of the live document at test time (from its path key up to the next
top-level path key), so both edits are scoped to /discover without a
hand-maintained duplicate to keep in sync.

Also documented, rather than silently accepted, that
TestWriteCacheWrapsACreateTempFailure is a no-op under a root-run CI
container: it needs a read-only directory to force CreateTemp's own
failure, and permission bits are exactly what root ignores. Unlike its
neighbour (a plain file blocking MkdirAll), there's no substitute
mechanism for THIS branch that root can't also bypass.
The value 8 existed under six local names across ten sites (resolution,
testResolution x2 in different package/package_test splits of the same
directory, indexResolution x2, res, and a bare cfg.Geo.ResolutionCells = 8
x3) — review on PR #18 found this PR added two more of them into
directories that already had the constant under a different name, which
is exactly how a repo-wide fixture value drifts without anyone deciding
to change it.

geo.DefaultTestResolution (src/indexing/geo/testsupport.go, a new file so
it stays visibly separate from the package's real exported API) is now
the one source every site references — including the ones this PR did not
touch, since they turned out to share the same problem the review named
one instance of. It is explicitly documented as test-only: production
takes its value from config.Geo.ResolutionCells (GEO_RESOLUTION_CELLS),
which happens to default to the same 8 for no reason either side depends
on.
…just claim it [#6]

Verified rather than asserted: writeCache runs MkdirAll(directory, ...)
and CreateTemp(directory, ...) against the same directory value, so the
reviewer's suggested alternative (block a path component with a plain
file, which fails ENOTDIR) trips MkdirAll first and never reaches
CreateTemp — it would just be TestWriteCacheWrapsAMkdirFailure again.
Confirmed with a standalone MkdirAll probe against a blocking file before
writing this down.
…coverage

test: close discover and publish unit/integration coverage gaps [#6][#1]
@manjudr
manjudr marked this pull request as ready for review September 8, 2026 12:38
@manjudr

This comment has been minimized.

The Trivy install script was fetched from aquasecurity/trivy's main
branch while only the binary version was pinned, so a compromised or
rewritten install.sh on main would run at build time with no version
gate — the exact supply-chain risk TRIVY_VERSION exists to close.
Fetching install.sh from the TRIVY_VERSION tag ref instead pins the
whole install path, not just the binary it downloads.

image-push and image-publish loop over IMAGE_REPOS/RELEASE_ARCHES with
plain `for ... do ... done` recipes. Without set -e, a failed docker
push or imagetools create on one iteration doesn't stop the loop and
doesn't fail the recipe unless the *last* command also fails — so a
partial, silent publish was possible. Adding `set -e` makes the first
failure abort the recipe immediately.
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Both issues fixed in 931a603 on feat/18-network-setup:

  1. Trivy installer now fetches contrib/install.sh from the $TRIVY_VERSION tag ref instead of main, pinning the whole install path.
  2. image-push and image-publish now run their for loops under set -e, so a failed docker push/imagetools create mid-loop aborts the recipe instead of being masked by a later command.

@manjudr
manjudr merged commit be472b3 into development Sep 9, 2026
2 checks passed
@manjudr
manjudr deleted the feat/18-network-setup branch September 9, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants