jiku

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 14, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

README

jiku-go

A Go client for Jiku's API, which lives on NATS rather than HTTP: 23 read endpoints (queries) and 23 write endpoints (commands), request/reply, no REST anywhere — plus the 16 domain events core publishes over JetStream (events).

This repo produces two things from the same code:

Artifact What it is
jiku a command-line tool for exploring and scripting the API
github.com/gravadigital/jiku-go a Go library to embed in a service
go install github.com/gravadigital/jiku-go/cmd/jiku@latest   # the CLI
go get github.com/gravadigital/jiku-go                       # the library

Go Reference


Why not just use the nats CLI?

You can, and the request is not complicated. But three things about this bus are easy to get wrong, and each one fails in a way that does not point at its cause:

1. The inbox prefix. Your connection may subscribe to exactly one inbox, _INBOX.<hash(your sub)>. Set anything else — including the random default every NATS client picks — and the reply is published somewhere you are not listening. The request then times out with no error: no permissions message, nothing in your logs. The violation is recorded in the NATS server's log, where nobody thinks to look. Their own permission template calls this "el error más caro de diagnosticar".

2. Authentication is two credentials, and only one of them matters. The .creds file grants nothing — its own JWT denies publish and subscribe on >. It exists to get you to the auth-callout. What actually mints your permissions is a Zitadel token, and it has to carry a roles claim, which it only does if you asked for the right scopes.

3. The bus accepting you says nothing about core accepting you. They are separate systems asking separate questions, and they refuse you with different errors for different reasons.

This client handles the first two, and jiku doctor tells the third apart from the others.


Quick start

# 1. Write a commented config file
jiku config init

# 2. Fill in three things (see Configuration below):
#      creds              path to the sentinel .creds file
#      zitadel.client_id  a Native Zitadel app with the Device Code grant
#      zitadel.project_id the Zitadel project — this is what puts ROLES in your token

# 3. Log in (opens a browser once; tokens are cached and refreshed silently)
jiku login

# 4. Check every link of the connection
jiku doctor
✓ config     instance=dev servers=nats://localhost:4222
✓ token      sub=275649063808925701 roles=admin,user
✓ bus        connected — the auth-callout accepted the token and minted permissions
✓ reply      meta.describe answered in 15ms; inbox prefix _INBOX.n3wi2tqwkmwccv4c is correct
✓ authorize  core authorised this caller — 16 resources visible

Then explore:

jiku describe                              # every resource the API serves
jiku describe tasks                        # one resource, with all five whitelists
jiku query tasks.list --filter projectId=15 --sort -createdAt --limit 10

The CLI

Discovering what exists

jiku describe asks the server, so it cannot go stale. It is not documentation about the API — it is the API's own validator, printed:

$ jiku describe -o table
RESOURCE             BASE  INCL  FILTER  SORT  DEFAULT SORT        LIMIT/MAX
requirements         12    17    14      8     -createdAt          50/200
tasks                14    6     15      6     -createdAt          50/200
...
16 resources. `jiku describe <resource>` for the whitelists.
Querying
jiku query tasks.list --filter projectId=15 --limit 10
jiku query tasks.get --id 7 --include person
jiku query requirements.list --all                    # follow every cursor
jiku query requirements.list --count-only             # just the total
jiku query requirements.tags --filter projectId=15

Filters. The bus decides the operator from the shape of the value, so the flag syntax is a surface over those shapes rather than an invention:

Flag Payload Meaning
--filter projectId=15 {"projectId": 15} equality
--filter state=analisis,activo {"state": ["analisis","activo"]} IN
--filter state!=cancelado {"state": {"not": "cancelado"}} negation
--filter 'createdAt>=2026-01-01' {"createdAt": {"gte": "..."}} range
--filter 'tag:modulo=facturacion' {"tag": {"key": "...", "value": "..."}} containment

Repeating a name merges range bounds, so a window reads the way you would type it:

jiku query requirements.list \
  --filter 'createdAt>=2026-01-01' \
  --filter 'createdAt<2026-07-01'

Repeating a name for anything else is an error rather than a silent overwrite.

Names and values are checked before the request goes out, against the same whitelists core validates against:

$ jiku query requirements.list --filter projctId=15
error: jiku: invalid request:
  - unknown filter "projctId"; did you mean "projectId"?
    allowed: createdAt, createdBy, estimatedFinishDate, ..., projectId, q, state, tag, type

$ jiku query requirements.list --filter state=noexiste
error: jiku: invalid request:
  - filter "state" does not accept "noexiste";
    allowed: analisis, planificacion, en_cola, desarrollo, revision, resuelto, cancelado

Pass --no-check to skip that and send exactly what you wrote.

Values are typed from the contract. A filter on an integer column sends 15, not "15" — which matters, because a string field whose values happen to be digits (a project code) must stay a string.

Writing
jiku cmd clients.new '{"name":"Acme"}'
jiku cmd requirements.12.edit '{"editor":"...","title":"..."}'
cat task.json | jiku cmd tasks.new -

An id goes in the method, not in the payload: requirements.12.edit.

Whether your identity may write over the bus is the deployment's call, and it is not the same answer for every command within a role — see Who can do what and docs/auth.md.

Output

-o json (default), -o table for reading, -o raw for byte-level comparison with the nats CLI. Pagination and progress go to stderr, so stdout stays a clean array:

jiku query tasks.list --all -o json | jq '[.[] | select(.state=="activo")] | length'
The escape hatch
jiku raw tasks.list '{"page":{"limit":1}}'                    # no validation, no help
jiku raw --envelope tasks.list '{}'                           # see status/errorCode too
jiku raw --service jiku-commands clients.new '{"name":"X"}'

The subject and inbox prefix are still built for you, because without those nothing answers.


The library

Everything the CLI does is available to a Go program.

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/gravadigital/jiku-go"
    "github.com/gravadigital/jiku-go/auth"
)

type Task struct {
    ID    int64  `json:"id"`
    Title string `json:"title"`
    State string `json:"state"`
}

func main() {
    ctx := context.Background()

    // A service authenticates with a Zitadel service account key: no browser, no
    // stored session, no refresh token to manage.
    src, err := auth.NewServiceUser(auth.ServiceUserConfig{
        Issuer:    "https://id.grava.io",
        KeyFile:   "/etc/jiku/service-account.json",
        ProjectID: "275672248377933829",
    })
    if err != nil {
        log.Fatal(err)
    }

    client, err := jiku.Connect(ctx, jiku.Config{
        Servers:  "nats://localhost:4222",
        Instance: "dev",
        Creds:    "/etc/jiku/sentinel-client.creds",
        Auth:     src,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    col, err := client.List(ctx, "tasks", jiku.List{
        Filter:  jiku.F{"projectId": 15, "state": jiku.In("backlog", "activo")},
        Sort:    []string{"-createdAt"},
        Include: []string{"person"},
        Limit:   20,
    })
    if err != nil {
        log.Fatal(err)
    }

    var tasks []Task
    if err := col.Into(&tasks); err != nil {
        log.Fatal(err)
    }
    for _, t := range tasks {
        fmt.Printf("%d  %-40s %s\n", t.ID, t.Title, t.State)
    }
}

Connect is where the three easy-to-get-wrong things are handled: it sets the inbox prefix from the token's sub, takes the token from a TokenSource on every reconnect (so a long-lived connection that drops after the token expired comes back with a fresh one), and builds every subject for you.

Filter builders
jiku.F{
    "projectId": 15,                                    // equality
    "state":     jiku.In("analisis", "planificacion"),  // IN
    "type":      jiku.Not("otro"),                      // negation
    "createdAt": jiku.Between("2026-01-01", "2026-07-01"),
    "updatedAt": jiku.Gte("2026-06-01"),
    "tag":       jiku.Contains("modulo", "facturacion"),
}
Pagination

The absence of a cursor is the only end-of-collection signal — there is no hasMore, and a page can come back shorter than the limit because of a byte budget. So do not hand-roll the loop:

it := client.Iterate(ctx, "tasks", jiku.List{Filter: jiku.F{"projectId": 15}})
for it.Next() {
    var t Task
    if err := it.Item().Into(&t); err != nil {
        return err
    }
    // ...
}
if err := it.Err(); err != nil {
    return err
}

Or client.All(ctx, "tasks", query, &tasks) when the collection is known to be small.

Errors
_, err := client.Get(ctx, "tasks", jiku.Get{ID: 999999})

switch {
case jiku.IsCode(err, jiku.CodeTaskNotFound):
    // A *_not_found does NOT distinguish "does not exist" from "you may not see it".
case jiku.IsCode(err, jiku.CodeCallerNotAuthorized):
    // The bus let you through and CORE refused you. Different question, different fix.
case errors.Is(err, jiku.ErrTimeout):
    // Nothing replied. On this bus, suspect the instance or the method before core.
case errors.Is(err, jiku.ErrFailure):
    var e *jiku.Error
    errors.As(err, &e)
    log.Printf("%s: %s (allowed: %v)", e.Code, e.Message, e.Details.Allowed)
}
Validating before you send
contract, _ := client.Contract(ctx)          // cached per client
tasks, _ := contract.Resource("tasks")

query := jiku.List{Filter: jiku.F{"projectId": 15}, Sort: []string{"-createdAt"}}
if err := tasks.Validate(query); err != nil {
    return err     // names the bad name and lists the alternatives
}

Runnable examples are in examples/.


Configuration

Resolved flag > environment > file > default.

File key Environment What it is
servers JIKU_SERVERS NATS URLs, comma separated
instance JIKU_INSTANCE dev or prod — the first token of every subject
creds JIKU_CREDS path to the sentinel .creds file
timeout JIKU_TIMEOUT per-request timeout (keep it above 10s)
zitadel.issuer JIKU_ISSUER e.g. https://id.grava.io
zitadel.client_id JIKU_CLIENT_ID Native app with the Device Code grant, for jiku login
zitadel.project_id JIKU_PROJECT_ID this is what puts roles in your token
zitadel.key_file JIKU_KEY_FILE service account key, for unattended use

jiku config show prints the effective values and where the file was looked for.

Two settings are worth dwelling on:

  • instance is the first token of every subject. Point it at the wrong deployment and nobody is subscribed to what you publish — and the symptom is a timeout, not an error.
  • project_id adds the two reserved Zitadel scopes that put your roles in the token. The auth-callout matches its rules on the role and has no catch-all, so a token without roles connects to nothing, and the only error you get is Authorization Violation.

Nothing secret is in this repo. The sentinel creds and any service account key are yours to place; jiku doctor tells you when one is missing or unreadable.


Authenticating

As a person — jiku login

The device authorization grant (RFC 8628). You approve once in a browser; tokens are cached at ~/.config/jiku/tokens-<instance>.json (mode 0600) and refreshed silently.

As a service — a key file
jiku doctor --key-file /etc/jiku/service-account.json
export JIKU_KEY_FILE=/etc/jiku/service-account.json

The JSON key Zitadel produces when you add a key to a machine user. No browser, no stored session: the key mints a token whenever one is needed.

Two requirements on the Zitadel side, both of which fail confusingly if missed:

  1. Access Token Type must be JWT. Left on the default (Bearer), the token is opaque and the callout cannot read it.
  2. The machine user needs a role in the project. No role, no matching callout rule, no connection.

Passing the bus is not the same as passing core. Core keeps its own role → method map, separate from the bus's permission templates, and it is deployment policy — it changes with a deploy. Two things about it repeatedly surprise people:

  • Core authorises the api by its sub (CORE_TRUSTED_PUBLISHER_ID), not by its role. So a role can be a full grant for the api and grant nothing to a second identity holding it.
  • Core also needs a row in its users table for any caller that is not that trusted publisher, created from an event the callout publishes on connect.

jiku doctor finds out which of these applies by asking core, instead of guessing. See docs/auth.md.


Who can do what

Roles decide everything, and two independent layers decide it — which is the single most useful thing to know when something is refused, because they refuse differently:

Asks Refuses with When
the bus may this connection publish this subject? Permissions Violation at publish, before core sees anything
core may this caller run this method? a failure envelope with an errorCode after

The bus enforces the auth-callout's permission template for your role; core enforces its own role → method map plus a row in its users table. Neither is readable from a client, and a change of policy has to land in both to take effect — so a deployment can sit halfway, with core authorising a method the bus will not let you publish.

Typical policy at the time of writing:

Role Queries Commands
admin all 23 20 direct, +1 admin-only direct (21 total)
user all 23 20 direct, +1 more only via the api's actor envelope
external-user all 23 0 direct, 6 only via the api's actor envelope
internal-app (the api) all all 21, direct
core, bus-observer none none

The reads are contractual; the writes are policy, at two levels within a role, not one. Jiku's own command contract states the three product roles get every query, so that row is stable. The command columns are deployment policy and move with a deploy — this client asserts none of it — but the SHAPE of the split is worth knowing: reaching a command "only via the envelope" means the api can do it on your behalf and you cannot do it yourself by publishing directly. week-assigned-times.replace is admin-only for exactly this reason (C-38): giving user direct access to it would let any user assign any week, which the portal never allowed.

Since REQ-007, core is the only validation point for writes: the worked-hours window, who may charge hours to whom, and the frozen past weeks of assignment all run there now, not in the api. The requirement state workflow was on that list until REQ-012 removed it — state transitions are free, in either direction, and no layer validates a sequence.

jiku whoami reports what your roles usually allow, split by whether a command is reachable directly or only through the api. jiku doctor stops guessing and asks: it reports which layer refused you, which is the question worth answering when a write fails.


Compatibility

This module follows semantic versioning, and this section says exactly what that covers — because a v1 that promises more than it can keep is worse than no promise at all.

go get github.com/gravadigital/jiku-go@v1

Covered. A breaking change here requires a new major version:

  • Every exported identifier in the root jiku package and in auth: types, functions, methods, struct fields, constants, and the sentinel errors.
  • The documented behaviour of those: which sentinel a failure matches, what Connect configures, the wire shape List and Get produce.
  • The Config fields and their meaning, and the JIKU_* environment variable names.
  • The CLI's command and flag names, and whether it exits zero.

Not covered. These can change in a patch or minor release:

  • The text of error messages. Branch on the sentinels (ErrTimeout, ErrFailure, ErrNoEndpoint, …) and on IsCode, never on message text. The wording exists to be improved.
  • The CLI's human-readable output — tables, doctor's prose, progress on stderr. -o json is stable in as much as it passes the server's own data through; the framing around it is not. Specific non-zero exit codes are not covered either, only zero versus non-zero.
  • Struct growth. Fields may be added to Contract, Resource, Variant and Field as meta.describe grows. Adding a field is backward-compatible in Go unless you build these with unkeyed composite literals — so use field names, and treat these four types as read-only data the server filled in.
  • The Go version floor in go.mod, which tracks what the dependencies require.
  • examples/, docs/, testdata/, and anything reachable only from a test.

Not ours to promise at all. Resource names, field names, enum values, page limits, error codes, and which role authorises what are Jiku's contract, not this library's. They are served by meta.describe and by core's own configuration, and they change when the deployment changes. That is precisely why this client fetches them instead of compiling them in — see docs/protocol.md.

Releasing

Development happens on dev; releases are cut from main, by pushing a tag.

# 1. merge dev into main through a pull request, and let CI pass

# 2. add the version's section to CHANGELOG.md, then:
make tag VERSION=v1.1.0     # runs the gate, refuses the mistakes that cannot be undone
git push origin main v1.1.0

Pushing the tag runs release.yml: the full gate again against that exact commit, then cross-compiled binaries and checksums attached to a GitHub release.

A published tag is permanent. For a Go module the tag is the release — the first time anyone asks for it, proxy.golang.org fetches and caches it. Deleting the tag from GitHub does not unpublish it. So a bad release is superseded by the next patch version, and pulled back with a retract directive in go.mod; a tag is never moved or reused. make tag refuses a duplicate tag, a dirty tree, the wrong branch, a non-semver version, and a version missing from the changelog, for that reason.

Going to v2 or beyond additionally requires changing the module path to end in /v2, which changes every consumer's import lines. release.yml checks the tag's major against go.mod and fails the release rather than publishing a version go get cannot resolve.

Documentation

Document Contents
docs/auth.md the auth chain, link by link, and every way it breaks
docs/protocol.md subjects, the envelope, error codes, pagination
docs/library.md the Go API in depth
docs/commands.md field reference for the 23 write commands
docs/events.md the domain event stream: permissions, filters, delivery
docs/sync-jiku.md how this client is kept in step with Jiku's contract
CONTRACT.md which commit of Jiku's contract this was last verified against
CHANGELOG.md what changed in each release
CONTRIBUTING.md the gate, the layout, and the bar for a change
SECURITY.md how to report a vulnerability, and where the boundary is

Also pkg.go.dev — where the runnable examples in example_test.go render alongside the API — and jiku <command> --help, whose long help carries the same explanations as these documents.


Layout

/                 package jiku — the library. Its import path IS the module path.
/auth/            token sources: the device flow and service users
/cmd/jiku/         the CLI, a thin shell over the library
/docs/            protocol, auth, library and command-reference guides
/tools/gendocs/   regenerates docs/commands.md from Jiku's own contract — never hand-edit it
/examples/        runnable programs
/testdata/        real server replies, used as fixtures

The library sits at the module root, which is the official layout for a repository holding both an importable package and a command — so import "github.com/gravadigital/jiku-go" gives you the library and pkg.go.dev/github.com/gravadigital/jiku-go is its documentation, with no extra path element to guess.

There is no internal/, and that is not an omission: internal/ is compiler-enforced unimportable from outside the module, so putting the library there would make it impossible to consume. It is where shared CLI code would go if cmd/ grew a second binary.

Development

make ci         # gofmt + go vet + go test -race — exactly what CI runs
make build      # ./bin/jiku
make dist       # cross-compiled binaries + checksums
make help       # every target

Tests need no network and no bus. The contract-decoding tests run against a real meta.describe reply saved in testdata/, and the inbox-hash test pins values observed from a running auth-callout — both are regression tests for bugs found by comparing this client against the live system rather than against the specs.

make ci runs the tests under the race detector, because this package documents Client and the token sources as safe for concurrent use and nats.go calls the token handler and the async error handler from its own goroutines.

See CONTRIBUTING.md for what this codebase is trying to be, and SECURITY.md for where the security boundary actually is (it is not this client).

License

Apache-2.0

Documentation

Overview

Package jiku is a client for Jiku's API, which is served over NATS rather than HTTP: 23 read endpoints (queries) and 23 write endpoints (commands), request/reply, no REST anywhere.

Core also PUBLISHES 16 domain events, over JetStream rather than core NATS and with entirely different delivery guarantees. That plane lives in the events subpackage, which is opt-in: importing this one costs nothing to a caller that never consumes events.

Getting started

src, err := auth.NewServiceUser(auth.ServiceUserConfig{
    Issuer:    "https://id.grava.io",
    KeyFile:   "/etc/jiku/service-account.json",
    ProjectID: "275672248377933829",
})

client, err := jiku.Connect(ctx, jiku.Config{
    Servers:  "nats://localhost:4222",
    Instance: "dev",
    Creds:    "/etc/jiku/sentinel-client.creds",
    Auth:     src,
})
defer client.Close()

col, err := client.List(ctx, "tasks", jiku.List{
    Filter: jiku.F{"projectId": 15, "state": jiku.In("backlog", "activo")},
    Sort:   []string{"-createdAt"},
    Limit:  20,
})

var tasks []Task
err = col.Into(&tasks)

Why this package exists rather than a bare NATS client

The request itself is not complicated. Three things about this bus are, and each fails in a way that does not point at its cause:

THE INBOX PREFIX. A connection may subscribe to exactly one inbox, _INBOX.<hash(sub)>. Anything else — including the random default every NATS client generates — means the reply is published where you are not listening. The request then times out with no error visible to the caller: the permissions violation is recorded in the NATS SERVER's log. Connect always sets it; see InboxPrefix.

TWO CREDENTIALS, ONE OF WHICH GRANTS NOTHING. The sentinel creds file denies itself publish and subscribe on ">". What mints permissions is a Zitadel access token, and it must carry a roles claim — which it only does if the reserved Zitadel scopes were requested. See the auth subpackage.

TOKENS AND RECONNECTS. The auth-callout evaluates the token at connect time, and NATS does not re-check afterwards. A reconnect re-runs the callout, so a token that expired in the meantime means the reconnect is refused. Connect uses nats.TokenHandler, called on every reconnect, rather than a frozen string.

Reads are deny-by-default

Every resource declares five closed lists — base, includable, filterable, sortable and an external scope. A name that is not declared DOES NOT EXIST: it answers invalid_fields, never a silently ignored lever. An ignored filter would return more data than was asked for.

Fetch those lists with Client.Contract, which calls meta.describe — the same structures the server's validator reads, so they cannot drift from it. Resource.Validate checks a query against them before it is published.

Filters: the operator is the shape of the value

scalar                      equality
array                       IN
{"not": ...}                negation
{"gte": x, "lte": y}        range
{"key": k, "value": v}      containment

Use F together with In, Not, Between, Gte and Contains rather than writing the maps by hand.

Pagination

The ABSENCE of a cursor is the only end-of-collection signal. There is no hasMore, and a page can come back shorter than the limit because of a byte budget — so a short page does not mean the end. Use Client.Iterate rather than a hand-rolled loop.

Reads and writes are not symmetric

The product roles (admin, user, external-user) authorise every query and NO command, enforced both by the bus permission template and by core's own role map. Writes go through the api over HTTP, because core does not hold the business rules that depend on the end user. Commands are for service identities.

Errors

A failure envelope becomes an *Error, which matches errors.Is(err, ErrFailure). Test a specific code with IsCode, and call (*Error).Hint for advice on the codes whose name does not explain the cause. Requests this package rejects locally — a forbidden identity field, an undeclared name — match ErrInvalidRequest and never reach the network.

Further reading

The repository's docs directory carries the protocol in detail, the authentication chain link by link, and the two AsyncAPI contracts that are the source of truth.

Index

Examples

Constants

View Source
const (
	DefaultTimeout  = 15 * time.Second
	DefaultInstance = "dev"
	DefaultIssuer   = "https://id.grava.io"
)

Default values. NATS_QUERY_TIMEOUT_MS is 10s on the server side and PostgreSQL's statement_timeout is 8s, so the database cuts first and the caller gets `query_timeout` rather than silence. A client timeout below 10s would break that ordering and turn an explained failure back into a mute one, so the default sits above it.

View Source
const (
	EnvServers   = "JIKU_SERVERS"
	EnvInstance  = "JIKU_INSTANCE"
	EnvCreds     = "JIKU_CREDS"
	EnvTimeout   = "JIKU_TIMEOUT"
	EnvIssuer    = "JIKU_ISSUER"
	EnvClientID  = "JIKU_CLIENT_ID"
	EnvProjectID = "JIKU_PROJECT_ID"
	EnvKeyFile   = "JIKU_KEY_FILE"
)

The environment variables, which override the file. A container gets configured with these.

View Source
const (
	// CodeInvalidFields is a name or value the resource sheet does not declare. Deny by
	// default: a name that is not whitelisted does not exist.
	CodeInvalidFields = "invalid_fields"
	// CodeInvalidCursor is a cursor that does not decode, or whose scope no longer matches
	// the filter and sort it was minted for.
	CodeInvalidCursor = "invalid_cursor"
	// CodeCallerNotAuthorized is gate 1: this caller may not run this method. Usually the
	// wrong role for the plane — a person publishing a command, for instance.
	CodeCallerNotAuthorized = "caller_not_authorized"
	// CodeUnknownCaller is gate 2: the caller has no row in `users`. A different question
	// from authorisation, and merging the two would erase the rule that an unknown caller
	// gets an error rather than an empty list.
	CodeUnknownCaller = "unknown_caller"
	// CodeUnknownCommand is a method that is not in the registry. Check the spelling
	// against `jiku describe`.
	CodeUnknownCommand = "unknown_command"
	// CodeQueryTimeout is PostgreSQL's statement_timeout (8s) firing before the bus
	// timeout (10s) — by design, so the caller gets an explanation instead of silence.
	CodeQueryTimeout = "query_timeout"
	// CodeInternalError is the dispatcher's catch. The dispatcher never throws, because a
	// thrown exception would become a mute bus timeout on the caller's side.
	CodeInternalError = "internal_error"

	// The *_not_found codes. On a `get` they do NOT distinguish "does not exist" from "you
	// may not see it": answering a permission error would confirm to an external caller that
	// the resource exists.
	CodeClientNotFound       = "client_not_found"
	CodeProjectNotFound      = "project_not_found"
	CodeRequirementNotFound  = "requirement_not_found"
	CodeTaskNotFound         = "task_not_found"
	CodeCommentNotFound      = "comment_not_found"
	CodeFileNotFound         = "file_not_found"
	CodePersonNotFound       = "person_not_found"
	CodeObjectiveNotFound    = "objective_not_found"
	CodeUserNotFound         = "user_not_found"
	CodeWorkedTimeNotFound   = "worked_time_not_found"
	CodeUnworkedTimeNotFound = "unworked_time_not_found"
	CodeSubscriptionNotFound = "subscription_not_found"

	// Emitted by commands only. These are business-rule refusals rather than shape errors, so
	// a caller that retries the same request gets the same answer.
	CodeFileNotOwned               = "file_not_owned"
	CodeAlreadySubscribed          = "already_subscribed"
	CodeDailyLimitExceeded         = "daily_limit_exceeded"
	CodeFileTooLarge               = "file_too_large"
	CodeFileTypeNotAllowed         = "file_type_not_allowed"
	CodeInvalidResponsiblePerson   = "invalid_responsible_person"
	CodeRequirementProjectMismatch = "requirement_project_mismatch"
	// CodeResolutionRequired is the mandatory conclusion on resolve. REQ-012 narrowed it
	// back to requirements of type `incidencia`: resolving any other type no longer needs a
	// resolution type or a conclusion.
	CodeResolutionRequired = "resolution_required"
	CodeInvalidDateRange   = "invalid_date_range"
	// CodeCommentNotOwned and CodeActivityNotEditable are the two codes REQ-011 added with
	// the comment-editing commands. Only the comment's author or an admin may edit one, and
	// the entry must actually be a comment — an activity row of any other kind is not
	// editable even for its own author.
	CodeCommentNotOwned     = "comment_not_owned"
	CodeActivityNotEditable = "activity_not_editable"
	// CodeInvalidStateTransition HAS NO CURRENT EMITTER. It was the requirement state
	// workflow refusing a transition, until REQ-012 made transitions free by product
	// decision: `requirements.{id}.edit` and `.resolve` stopped emitting it. Core keeps it in
	// its own catalog on purpose (the catalog is not closed), so it is kept here too — do not
	// assume it is unreachable.
	CodeInvalidStateTransition = "invalid_state_transition"
	CodeStageNotFound          = "stage_not_found"
	// CodeAccessDenied is the project-permission refusal: the caller may run the method, but
	// not against this project. Distinct from CodeCallerNotAuthorized, which is about the
	// method itself.
	CodeAccessDenied = "access_denied"

	// CodeFileNotAvailable and CodeInvalidAttachmentID are declared for completeness but have
	// no current emitter — core's own catalog keeps them on purpose, since the catalog is not
	// closed and nothing yet asks for their removal. Do not assume they are unreachable.
	CodeFileNotAvailable    = "file_not_available"
	CodeInvalidAttachmentID = "invalid_attachment_id"
)

The shared error catalog. One catalog for both planes, not one per plane.

The HTTP column of the spec is documentation for a future consumer, not behaviour, so it is deliberately not mapped here.

View Source
const (
	// ServiceQueries serves the 23 read endpoints. Every product role may publish here.
	ServiceQueries = "jiku-queries"
	// ServiceCommands serves the 23 write commands. Since REQ-007 `admin` and `user` publish
	// most of them directly; `external-user` still writes only through the api. Which command
	// a role reaches, and whether it reaches it directly or only as a side effect of the api
	// acting on its behalf, is the role table in docs/auth.md.
	ServiceCommands = "jiku-commands"
)

Service names of the two micro services core registers on the bus.

They are separate subject tokens on purpose, not nested under one prefix: two queue groups over overlapping subjects would deliver each message to BOTH subscriptions, and a plain request() returns the first reply and discards the second silently.

View Source
const ProtocolVersion = "v1"

ProtocolVersion is the {version} token of the subject grammar.

Variables

View Source
var (
	// ErrInvalidRequest is a request this library rejected locally, before publishing,
	// because core would answer invalid_fields. Fix the call.
	ErrInvalidRequest = errors.New("jiku: invalid request")
	// ErrFailure is a `status: failure` reply. Inspect it as *Error for the code.
	ErrFailure = errors.New("jiku: core answered failure")
	// ErrTimeout is a bus timeout: no reply arrived. On this bus the first suspect is a
	// wrong inbox prefix, not a slow core — see InboxPrefix.
	ErrTimeout = errors.New("jiku: no reply from core")
	// ErrNotConnected is use of a Client that was closed or never connected.
	ErrNotConnected = errors.New("jiku: not connected")
	// ErrNoEndpoint means nothing is subscribed to the subject: the bus said so immediately,
	// rather than the request timing out. It is a firmer signal than a timeout — the method
	// almost certainly does not exist, or it was asked on the wrong plane or instance.
	ErrNoEndpoint = errors.New("jiku: no endpoint for that method")
)

Sentinel errors for the failure classes a caller can act on with errors.Is.

Functions

func Between

func Between(from, to any) map[string]any

Between is the closed range, inclusive on both ends.

func ConfigFile

func ConfigFile() string

ConfigFile is the default config path: $XDG_CONFIG_HOME/jiku/config.yaml.

func Contains

func Contains(key, value any) map[string]any

Contains builds a containment condition, valid only where the resource sheet declares the filterable as `contains` — `requirements.tags` is the case that exists today.

func Gt

func Gt(v any) map[string]any

Gt, Gte, Lt and Lte are the one-sided ranges, which is what most calls need.

func Gte

func Gte(v any) map[string]any

func HashUserID

func HashUserID(userID string) string

HashUserID derives the inbox token for a user id.

It mirrors HashUserID in the auth-callout (internal/authz/identity.go) byte for byte. The two implementations MUST agree: the callout uses it to mint the subscribe permission and the client uses it to pick its inbox, with no channel between them.

This hash hides nobody. The user id travels raw in every subject, so anyone who can see a subject has already seen the id — the inbox just needs one opaque, fixed-length token.

func In

func In(values ...any) []any

In builds an IN condition. A single value is still sent as an array, which core reads as a one-element IN — identical in meaning to equality.

func InboxPrefix

func InboxPrefix(userID string) string

InboxPrefix is the only inbox a caller is allowed to subscribe to:

_INBOX.<HashUserID(sub)>

THIS IS THE MOST EXPENSIVE MISTAKE ON THIS BUS

The callout grants `sub.allow: _INBOX.{{user_id_hash}}.>` and nothing else. A client that does not set this prefix gets nats.go's default random `_INBOX.<nuid>`, which no permission authorises. The reply is then published to a subject the client is not subscribed to, so:

  • the request TIMES OUT after the full timeout window,
  • no permissions error is returned to the caller,
  • and the violation is logged by the NATS SERVER, where nobody thinks to look.

The symptom points at core being down or slow. It is neither. Connect() always sets this, which is a large part of why this package exists — see docs/auth.md.

Example

The inbox prefix, and why it is the most expensive mistake on this bus.

A connection may subscribe to exactly one inbox. Set anything else — including the random default every NATS client generates — and replies are published where nobody is listening: the request times out with no error visible to the caller. Connect always sets this; you only need it when building your own nats.Conn.

package main

import (
	"fmt"

	"github.com/gravadigital/jiku-go"
)

func main() {
	fmt.Println(jiku.InboxPrefix("275649063808925701"))
}
Output:
_INBOX.n3wi2tqwkmwccv4c

func IsCode

func IsCode(err error, code string) bool

IsCode reports whether err is a core failure carrying the given code.

if jiku.IsCode(err, jiku.CodeTaskNotFound) { ... }
Example

Branching on failures.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/gravadigital/jiku-go"
)

func main() {
	var client *jiku.Client
	ctx := context.Background()

	_, err := client.Get(ctx, "tasks", jiku.Get{ID: 999999})
	switch {
	case jiku.IsCode(err, jiku.CodeTaskNotFound):
		// Note: this does NOT distinguish "does not exist" from "you may not see it".
		fmt.Println("not found")

	case errors.Is(err, jiku.ErrInvalidRequest):
		// Rejected locally, before publishing. Never reached the network.
		fmt.Println("bad request:", err)

	case errors.Is(err, jiku.ErrNoEndpoint):
		// Nothing is subscribed to that subject: usually a misspelled method.
		fmt.Println("no such method")

	case errors.Is(err, jiku.ErrTimeout):
		// Nothing replied. Suspect the instance or the method before suspecting core.
		fmt.Println("timeout")

	case errors.Is(err, jiku.ErrFailure):
		// Any other refusal from core. The catalog is core's and it grows, so do not switch
		// exhaustively — read the code and the details.
		var e *jiku.Error
		errors.As(err, &e)
		fmt.Println(e.Code, e.Message)
		if e.Details != nil {
			fmt.Println(e.Details.Field, e.Details.Allowed)
		}
		if hint := e.Hint(); hint != "" {
			fmt.Println(hint)
		}
	}
}

func Lt

func Lt(v any) map[string]any

func Lte

func Lte(v any) map[string]any

func Not

func Not(value any) map[string]any

Not negates a scalar or a set.

func Range

func Range(gt, gte, lt, lte any) map[string]any

Range builds a bounded condition from any of gt, gte, lt, lte. Nil bounds are omitted, so Range(nil, x, nil, nil) is an open-ended lower bound.

func SplitMethod

func SplitMethod(method string) (resource, operation string, ok bool)

SplitMethod splits a method like "tasks.list" into its resource and operation.

func Subject

func Subject(instance, userID, service, method string) string

Subject builds a request subject from the grammar core subscribes to:

{instance}.{userID}.{service}.{version}.{method}
dev.275649063808925701.jiku-queries.v1.tasks.list

userID is the Zitadel token's `sub`, RAW, and it is the only source of caller identity: the auth-callout authorises publishing under one's own id only, so the subject cannot be forged while the body can. That is also why identity field names are rejected in payloads (see forbiddenIdentityFields).

Example

Building a subject by hand, for the rare case something needs one.

package main

import (
	"fmt"

	"github.com/gravadigital/jiku-go"
)

func main() {
	fmt.Println(jiku.Subject("dev", "275649063808925701", jiku.ServiceQueries, "tasks.list"))
}
Output:
dev.275649063808925701.jiku-queries.v1.tasks.list

Types

type Client

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

Client is a connection to Jiku's bus.

It is safe for concurrent use and should be long-lived: one per process, not one per request. Connecting costs a round trip to the identity provider and a NATS handshake that runs the auth-callout.

func Connect

func Connect(ctx context.Context, cfg Config) (*Client, error)

Connect opens the bus connection.

It does three things a hand-rolled nats.Connect does not, and each one is a failure mode somebody has already spent an afternoon on:

  1. It sets the inbox prefix to _INBOX.<hash(sub)>. Without it every request times out with no error anywhere the caller can see. See InboxPrefix.
  2. It takes the token from a TokenSource on every (re)connect via nats.TokenHandler, so a reconnect after the token expired re-authenticates instead of being refused.
  3. It derives the caller identity from the token's `sub`, so no subject has to be written by hand and none can disagree with the credential presenting it.
Example

Connecting as a service, which is what an unattended integration should do.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
	"github.com/gravadigital/jiku-go/auth"
)

func main() {
	ctx := context.Background()

	// The key is the JSON file Zitadel produces for a machine user. ProjectID is what puts the
	// ROLES in the token, and the auth-callout matches its rules on the role — so a token
	// minted without it connects to nothing.
	src, err := auth.NewServiceUser(auth.ServiceUserConfig{
		Issuer:    "https://id.grava.io",
		KeyFile:   "/etc/jiku/service-account.json",
		ProjectID: "275672248377933829",
	})
	if err != nil {
		log.Fatal(err)
	}

	client, err := jiku.Connect(ctx, jiku.Config{
		Servers:  "nats://localhost:4222",
		Instance: "dev",
		Creds:    "/etc/jiku/sentinel-client.creds",
		Auth:     src,
	})
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	fmt.Println(client.UserID(), client.InboxPrefix())
}
Example (Person)

Connecting as a person, reusing the session `jiku login` stored.

package main

import (
	"context"
	"errors"
	"log"

	"github.com/gravadigital/jiku-go"
	"github.com/gravadigital/jiku-go/auth"
)

func main() {
	ctx := context.Background()

	src, err := auth.NewDeviceFlow(auth.DeviceConfig{
		Issuer:    "https://id.grava.io",
		ClientID:  "385696162499330050@gestor_de_proyectos",
		ProjectID: "275672248377933829",
		Store:     auth.DefaultStore("dev"),
	})
	if err != nil {
		log.Fatal(err)
	}

	// Token never opens a browser. It reports that one is needed, so this same code is safe to
	// run unattended — only Login is interactive.
	if _, err := src.Token(ctx); errors.Is(err, auth.ErrLoginRequired) {
		log.Fatal("run `jiku login` first")
	}

	cfg := jiku.FromEnv()
	cfg.Auth = src
	client, err := jiku.Connect(ctx, cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()
}

func (*Client) All

func (c *Client) All(ctx context.Context, resource string, q List, dest any) error

All collects every item of a list into dest, following every cursor.

Convenient and dangerous in the same way: it holds the whole collection in memory and issues as many requests as it takes. Use Iterate for anything that might be large.

func (*Client) Close

func (c *Client) Close() error

Close drains and closes the connection.

func (*Client) Command

func (c *Client) Command(ctx context.Context, method string, payload any) (json.RawMessage, error)

Command publishes to the write plane and returns the envelope's data, or a *Error on failure.

A COMMAND IS NOT THE MIRROR IMAGE OF A QUERY

Three asymmetries, all deliberate on core's side:

  • Which caller may run which command is deployment policy, decided per role AND per command by two independent layers — the bus template and core's role map — and it can differ WITHIN one role: a role may publish some commands directly and reach others only as a side effect of the api acting on its behalf (the reserved `actor` envelope, rejected from anyone else). See docs/commands.md.
  • The acting person travels in the BODY (`creator`, `author`, `editor`), because the subject identifies the SERVICE that published, not the human behind it. Several of these fields are optional: core resolves the actor from the caller when absent.
  • There is no JetStream and no retry. If core is down the request times out and the operation did not happen.

func (*Client) Conn

func (c *Client) Conn() *nats.Conn

Conn exposes the underlying NATS connection, for callers that need something this package does not wrap. The connection is already correctly authenticated and has the right inbox prefix, so building on it is safe.

func (*Client) ConnectedURL

func (c *Client) ConnectedURL() string

ConnectedURL is the server actually in use, which matters when Servers listed several.

func (*Client) Contract

func (c *Client) Contract(ctx context.Context) (*Contract, error)

Contract returns the full contract, fetching it once per client and caching it.

The cache is per Client, so it lives as long as the connection and no longer. Nothing is written to disk here: a contract cached across runs is a contract that can be wrong after a deploy, and this one costs a single request that touches no database.

func (*Client) Describe

func (c *Client) Describe(ctx context.Context, resources ...string) (*Contract, error)

Describe fetches the contract, for all resources or for the named ones.

An EMPTY (non-nil) resources slice is invalid_fields on the server, not "all" — so nil and empty are collapsed here to mean "all", which is what a caller passing no arguments means.

func (*Client) Get

func (c *Client) Get(ctx context.Context, resource string, q Get) (*Item, error)

Get runs a `{resource}.get`.

A *_not_found does not distinguish "does not exist" from "you may not see it", on purpose: telling them apart would confirm to an external caller that the record exists.

Example

Fetching one record.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
)

// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	State string `json:"state"`
}

func main() {
	var client *jiku.Client
	ctx := context.Background()

	item, err := client.Get(ctx, "tasks", jiku.Get{ID: 7, Include: []string{"person"}})
	if err != nil {
		log.Fatal(err)
	}
	var task Task
	if err := item.Into(&task); err != nil {
		log.Fatal(err)
	}
	fmt.Println(task.Title)
}

func (*Client) InboxPrefix

func (c *Client) InboxPrefix() string

InboxPrefix is the inbox this connection subscribes to, for diagnostics.

func (*Client) Instance

func (c *Client) Instance() string

Instance is the deployment token of every subject.

func (*Client) Iterate

func (c *Client) Iterate(ctx context.Context, resource string, q List) *Iterator

Iterate returns an Iterator over every page of a list.

Nothing is requested until the first call to Next.

Example

Walking every page.

Use this rather than a hand-rolled loop: the ABSENCE of a cursor is the only end-of-collection signal. A page shorter than the limit does not mean the end — the engine can cut one on a byte budget and still have more to give.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
)

// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	State string `json:"state"`
}

func main() {
	var client *jiku.Client
	ctx := context.Background()

	it := client.Iterate(ctx, "tasks", jiku.List{Filter: jiku.F{"projectId": 15}})
	for it.Next() {
		var t Task
		if err := it.Item().Into(&t); err != nil {
			log.Fatal(err)
		}
		fmt.Println(t.ID, t.Title)
	}
	if err := it.Err(); err != nil {
		log.Fatal(err)
	}
	fmt.Println(it.Count(), "items in", it.Pages(), "pages")
}

func (*Client) List

func (c *Client) List(ctx context.Context, resource string, q List) (*Collection, error)

List runs a `{resource}.list`.

col, err := c.List(ctx, "tasks", jiku.List{
    Filter: jiku.F{"projectId": 15},
    Sort:   []string{"-createdAt"},
    Limit:  20,
})
var tasks []Task
err = col.Into(&tasks)
Example

Listing one page, with filters, a sort and an includable.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
)

// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
	ID    int64  `json:"id"`
	Title string `json:"title"`
	State string `json:"state"`
}

func main() {
	var client *jiku.Client // from jiku.Connect
	ctx := context.Background()

	col, err := client.List(ctx, "tasks", jiku.List{
		Filter:  jiku.F{"projectId": 15, "state": jiku.In("backlog", "activo")},
		Sort:    []string{"-createdAt"},
		Include: []string{"person"},
		Limit:   20,
		Count:   jiku.CountOn,
	})
	if err != nil {
		log.Fatal(err)
	}

	var tasks []Task
	if err := col.Into(&tasks); err != nil {
		log.Fatal(err)
	}

	// Limit is the EFFECTIVE limit, after the resource's silent clamp. Returned can be lower
	// still, because the engine cuts a page on a byte budget.
	fmt.Println(col.Page.Limit, col.Page.Returned, col.Page.HasMore())
}

func (*Client) Query

func (c *Client) Query(ctx context.Context, method string, payload any) (json.RawMessage, error)

Query publishes to the read plane and returns the envelope's data, or a *Error on failure.

func (*Client) Request

func (c *Client) Request(ctx context.Context, service, method string, payload any) (*Reply, error)

Request publishes a request and returns the decoded envelope, WITHOUT turning a failure into an error. Use it when you want to inspect a failure rather than handle it as one; Query and Command are the usual entry points.

func (*Client) Tags

func (c *Client) Tags(ctx context.Context, projectID int64, key string) ([]TagGroup, error)

Tags runs `requirements.tags`, the one query with a shape of its own. It is not paginated.

func (*Client) UserID

func (c *Client) UserID() string

UserID is the caller identity in every subject: the Zitadel `sub`.

type Collection

type Collection struct {
	Items []json.RawMessage `json:"items"`
	Page  Page              `json:"page"`
}

Collection is the reply of a list: the raw items plus the page.

Items stay as raw JSON so a caller decodes into whatever shape they want — the returned field set changes with Fields and Include, so there is no one struct that fits.

func (Collection) Into

func (c Collection) Into(dest any) error

Into decodes the items into a slice pointer:

var tasks []Task
err := col.Into(&tasks)

type Config

type Config struct {
	// Servers is a comma-separated list of NATS URLs, e.g. "nats://localhost:4222".
	Servers string `yaml:"servers"`
	// Instance is the deployment token of every subject: "dev" or "prod". Getting it wrong
	// produces a request nobody is subscribed to, which looks exactly like a timeout.
	Instance string `yaml:"instance"`
	// Creds is the path to the sentinel NATS creds file.
	//
	// It grants no permissions by itself — the file's own JWT denies pub and sub on ">" —
	// and exists only to let the connection reach the auth-callout, which is what mints real
	// permissions from the Zitadel token.
	Creds string `yaml:"creds"`
	// Timeout is the per-request bus timeout. See DefaultTimeout for why 15s.
	Timeout time.Duration `yaml:"timeout"`
	// Name identifies this client in `nats server report connections`. Defaults to "jiku-cli".
	Name string `yaml:"name"`

	// Auth is the token source. Required, and the only thing that decides what you may do.
	Auth auth.TokenSource `yaml:"-"`

	// UserID overrides the caller identity used in subjects. LEAVE IT EMPTY: it is derived
	// from the token's `sub`, and the callout only authorises publishing under one's own id,
	// so a value that disagrees with the token produces an authorization violation rather
	// than access to somebody else's namespace. It exists for diagnostics.
	UserID string `yaml:"-"`

	// Zitadel holds the identity provider settings the CLI needs to obtain a token. A
	// library caller that builds its own Auth can ignore this entirely.
	Zitadel ZitadelConfig `yaml:"zitadel"`
}

Config is everything needed to connect. Load it from a file with LoadConfig, from the environment with FromEnv, or build it in code.

func FromEnv

func FromEnv() Config

FromEnv builds a config from the environment and the defaults, with no file involved.

func LoadConfig

func LoadConfig(path string) (Config, error)

LoadConfig reads a YAML config file, applies environment overrides and fills in defaults.

A missing file is not an error: the environment alone is a perfectly good way to configure this, and it is how a containerised service normally does it.

type ContainsShape

type ContainsShape struct {
	Shape []string `json:"shape"`
}

ContainsShape is the shape a containment filter accepts, e.g. ["key", "value"].

The type is named apart from the Contains constructor in query.go: this one DESCRIBES what the server accepts, that one BUILDS a value for it.

type Contract

type Contract struct {
	Resources map[string]Resource `json:"resources"`
}

Contract is what `meta.describe` returns: the five whitelists of every resource, as data.

WHY THIS IS WORTH FETCHING RATHER THAN HARDCODING

meta.describe projects THE SAME STRUCTURES the validator reads to reject names. So every name it declares works and one it does not declare answers invalid_fields — there is no second copy to drift. A table compiled into this library would be exactly that second copy.

It describes the CONTRACT, not the data, so it is identical for every caller. Knowing that an includable `email` exists grants access to no email: row trimming and the field whitelist still apply to every query.

func (*Contract) Resource

func (ct *Contract) Resource(name string) (Resource, error)

Resource looks a resource up, suggesting a near match when there is none.

func (*Contract) ResourceNames

func (ct *Contract) ResourceNames() []string

ResourceNames lists the resources in the contract, sorted.

type Count

type Count int

Count selects whether a list also returns the total.

const (
	// CountOff is the default: no total, one query.
	CountOff Count = iota
	// CountOn returns the collection AND the total. Opt-in because it costs a second query
	// over the whole universe of the filter.
	CountOn
	// CountOnly returns the total and DOES NOT execute the rows query.
	CountOnly
)

type Defaults

type Defaults struct {
	Sort []string `json:"sort"`
	// Limit is the page size applied when none is given.
	Limit int `json:"limit"`
	// MaxLimit is the cap. A limit above it is CLAMPED SILENTLY — success, not failure — so
	// this is the only place a caller can learn the real ceiling.
	MaxLimit int `json:"maxLimit"`
}

Defaults are a resource's default sort and page sizes.

type Discriminator

type Discriminator struct {
	Field  string   `json:"field"`
	Values []string `json:"values"`
}

Discriminator names the field that selects a variant.

On `comments.get` it is MANDATORY: the same id means different records under different entity types, so there is nothing sensible to default to.

type EnumValue

type EnumValue struct {
	Value string `json:"value"`
	Label string `json:"label,omitempty"`
}

EnumValue is one allowed value of an enum, with the label the UI shows.

type Error

type Error struct {
	Code    string
	Message string
	Details *ErrorDetails
	// Method is the method that failed, added by this library for context.
	Method string
}

Error is a `status: failure` reply, with everything core said about it.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Hint

func (e *Error) Hint() string

Hint returns advice for the failure codes whose cause is not obvious from the code alone. The CLI prints it; a library caller can too.

func (*Error) Is

func (e *Error) Is(target error) bool

Is makes every failure match ErrFailure, so callers can branch on the class before reaching for the code.

type ErrorDetails

type ErrorDetails struct {
	Field   string         `json:"field,omitempty"`
	Value   any            `json:"value,omitempty"`
	Allowed []string       `json:"allowed,omitempty"`
	Extra   map[string]any `json:"-"`
	// contains filtered or unexported fields
}

ErrorDetails is the structured half of a failure, so a caller never parses ErrorMessage with a regex.

The query plane populates it from day one: a rejected name comes back as {field, value, allowed}, where Allowed is the resource sheet's list by reference — which is exactly what makes meta.describe verifiable against the validator.

func (ErrorDetails) MarshalJSON

func (d ErrorDetails) MarshalJSON() ([]byte, error)

MarshalJSON round-trips the original bytes when they are available.

func (*ErrorDetails) UnmarshalJSON

func (d *ErrorDetails) UnmarshalJSON(b []byte) error

UnmarshalJSON keeps the known keys typed and every other key in Extra, so a field core starts sending tomorrow is not lost.

type F

type F map[string]any

F is a filter map. Conditions are ANDed together, and THE OPERATOR IS DECIDED BY THE SHAPE OF THE VALUE — that shape grammar is the contract:

scalar                          equality
array                           IN
{"not": scalar|array}           negation
{"gte": x, "lte": y}            range (gt, gte, lt, lte)
{"key": k, "value": v}          containment, where the sheet declares `contains`

Use the constructors below rather than writing the maps by hand:

jiku.F{
    "projectId": 15,                                   // equality
    "state":     jiku.In("analisis", "planificacion"), // IN
    "createdAt": jiku.Gte("2026-01-01"),               // range
    "type":      jiku.Not("otro"),                     // negation
}
Example

The filter builders, over the bus's shape-based operator grammar: the OPERATOR is decided by the SHAPE of the value.

package main

import (
	"fmt"

	"github.com/gravadigital/jiku-go"
)

func main() {
	filter := jiku.F{
		"projectId": 15,                                       // scalar  -> equality
		"state":     jiku.In("backlog", "activo"),             // array   -> IN
		"type":      jiku.Not("otro"),                         // {not}   -> negation
		"createdAt": jiku.Between("2026-01-01", "2026-07-01"), // {gte,lte}
		"updatedAt": jiku.Gte("2026-06-01"),                   // {gte}
		"tag":       jiku.Contains("modulo", "facturacion"),   // {key,value}
	}
	fmt.Println(len(filter))
}
Output:
6

func ParseFilter

func ParseFilter(exprs []string, r Resource) (F, error)

ParseFilter turns command-line filter expressions into a wire filter.

The bus decides the operator by the SHAPE of the value, so the flag syntax is a surface over those shapes rather than an invention of its own:

projectId=15                 {"projectId": 15}                          equality
state=analisis,activo        {"state": ["analisis","activo"]}           IN
state!=cancelado             {"state": {"not": "cancelado"}}            negation
createdAt>=2026-01-01        {"createdAt": {"gte": "2026-01-01"}}       range
createdAt<2026-07-01         {"createdAt": {"lt": "2026-07-01"}}        range
tags:modulo=facturacion      {"tags": {"key":"modulo","value":"..."}}   containment

Repeating a name MERGES range bounds, so the two halves of a window can be written separately, which is how anyone would type it:

--filter 'createdAt>=2026-01-01' --filter 'createdAt<2026-07-01'
  -> {"createdAt": {"gte": "2026-01-01", "lt": "2026-07-01"}}

A repeat that is NOT a range is an error rather than a silent overwrite: two conditions on one name would otherwise leave the caller believing both applied.

Values are typed by the resource contract when one is given: a filter on an integer sends 15, not "15". Pass a zero Resource to skip coercion and send everything as a string.

Example

Parsing filters from strings, which is what the CLI does with its --filter flags. Useful for anything else taking filters from config or a request.

package main

import (
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
)

func main() {
	// A zero Resource skips type coercion and sends everything as a string. Pass a real one to
	// have values typed from the contract's declared kind.
	filter, err := jiku.ParseFilter([]string{
		"projectId=15",
		"state=backlog,activo",
		"createdAt>=2026-01-01",
	}, jiku.Resource{})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(len(filter))
}
Output:
3

type Field

type Field struct {
	// Kind is what the name IS: field, computed, relation, or a scalar type (integer,
	// string, date, boolean, enum).
	//
	// It is what makes local type coercion possible: a filter on an integer must send 15,
	// not "15", because the comparison is decided by the JSON type.
	Kind string `json:"kind"`
	// Enum names the entry in Enums that lists the allowed values.
	Enum string `json:"enum,omitempty"`
	// Search marks a full-text filterable, conventionally `q`.
	Search bool `json:"search,omitempty"`
	// SearchNumeric marks a search filterable that also matches numbers.
	SearchNumeric bool `json:"searchNumeric,omitempty"`
	// Contains marks a filterable that takes a {key, value} containment shape instead of a
	// scalar. It is non-nil only where the resource sheet allows it; build the value with the
	// Contains constructor.
	Contains *ContainsShape `json:"contains,omitempty"`
	// Cardinality is "one" or "many" on a relation.
	Cardinality string `json:"cardinality,omitempty"`
	// Fields are the columns a relation includable brings back.
	Fields []string `json:"fields,omitempty"`
	// Scalar is set where a relation collapses to a single scalar column rather than an
	// object — `subscriptors` comes back as a list of `userId`, for instance.
	Scalar string `json:"scalar,omitempty"`
	// Optional marks a relation that may be null.
	Optional bool `json:"optional,omitempty"`
	// Cap is the per-row limit of a collection includable.
	Cap int `json:"cap,omitempty"`
	// TruncatedFlag is the SIBLING key that marks a row whose collection hit the cap. It is
	// a sibling of the collection (`commentsTruncated`), never a nested field.
	TruncatedFlag string `json:"truncatedFlag,omitempty"`
}

Field describes one name in a whitelist.

type Get

type Get struct {
	// ID is required.
	ID int64 `json:"id"`
	// Fields restricts the returned set.
	Fields []string `json:"fields,omitempty"`
	// Include adds includables.
	Include []string `json:"include,omitempty"`
	// EntityType is the discriminator, accepted as a fourth key only where a resource
	// declares one. On `comments` it is MANDATORY.
	EntityType string `json:"entityType,omitempty"`
}

Get is the payload of a `{resource}.get`.

Filter, Sort, Page and Count are an ERROR here, not an ignorable extra: a get asks about one identified resource, and accepting a filter in silence would let the caller believe something had been trimmed. This struct simply has nowhere to put them.

type Item

type Item struct {
	Raw json.RawMessage
}

Item is the reply of a get: the record, flat under data.

func (Item) Into

func (i Item) Into(dest any) error

Into decodes the item into a struct pointer.

type Iterator

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

Iterator walks every page of a list, following cursors.

It exists because the end of a collection is signalled by the ABSENCE of a cursor, and a hand-rolled loop that checks anything else — a page smaller than the limit, for instance — is wrong: the byte budget can cut a page short and still emit a cursor.

it := c.Iterate(ctx, "tasks", jiku.List{Filter: jiku.F{"projectId": 15}})
for it.Next() {
    var t Task
    if err := it.Item().Into(&t); err != nil { return err }
    fmt.Println(t.Title)
}
if err := it.Err(); err != nil { return err }

Iterating is not a snapshot: each page is its own query, so a record inserted between pages may appear and one deleted may vanish. The keyset cursor guarantees no row is SKIPPED for a stable ordering, which is the property that matters for a full sweep.

func (*Iterator) Count

func (it *Iterator) Count() int

func (*Iterator) Err

func (it *Iterator) Err() error

Err is the error that stopped the iteration, if any.

func (*Iterator) Item

func (it *Iterator) Item() Item

Item is the current item. Valid only after Next returned true.

func (*Iterator) Next

func (it *Iterator) Next() bool

Next advances to the next item, fetching the next page when the current one runs out. It returns false at the end of the collection and on error — check Err to tell them apart.

func (*Iterator) Page

func (it *Iterator) Page() Page

Page is the pagination block of the page currently being walked.

func (*Iterator) Pages

func (it *Iterator) Pages() int

Pages is how many requests have been made, and Count how many items have been yielded.

type List

type List struct {
	// Filter conditions, ANDed. See F for the shape grammar.
	Filter F `json:"filter,omitempty"`
	// Sort criteria in order; a leading "-" is descending. The engine always appends `id`
	// as the final tie-breaker, because the keyset cursor needs a total order.
	Sort []string `json:"sort,omitempty"`
	// Fields restricts the returned set to names from base ∪ includable. `id` is always
	// returned whether asked for or not.
	Fields []string `json:"fields,omitempty"`
	// Include adds includables. A collection includable with a cap returns at most `cap`
	// items per row and marks the row with its truncated flag.
	Include []string `json:"include,omitempty"`
	// Limit is the page size. A limit above the resource's maxLimit is CLAMPED SILENTLY —
	// success, not failure. Read the effective value back from Page.Limit.
	Limit int `json:"-"`
	// Cursor continues a previous page. Valid only for the exact filter and sort it was
	// minted for.
	Cursor string `json:"-"`
	// Count opts into the total.
	Count Count `json:"-"`
}

List is the payload of a `{resource}.list`. Six levers and no more: any other top-level key is invalid_fields, and so is any of the eleven forbidden identity names.

The NAMES inside Filter, Sort, Fields and Include are decided by the resource sheet. Fetch it with Client.Describe, or run `jiku describe <resource>`.

type Page

type Page struct {
	// Limit is the EFFECTIVE limit, with the default and the silent cap applied.
	Limit int `json:"limit"`
	// Returned is how many items this page carries. It can be fewer than Limit because of
	// the byte budget: the engine cuts the page before the reply exceeds what NATS accepts
	// and emits the cursor at the cut.
	Returned int `json:"returned"`
	// Cursor is absent on the last page.
	Cursor string `json:"cursor,omitempty"`
	// Total appears only when Count was requested.
	Total *int `json:"total,omitempty"`
}

Page is the pagination block of a list reply.

THE ABSENCE OF CURSOR IS THE ONLY END-OF-COLLECTION SIGNAL. There is no hasMore boolean, because two ways of saying the same thing eventually disagree.

func (Page) HasMore

func (p Page) HasMore() bool

HasMore reports whether another page exists, which is exactly "a cursor came back".

type Reply

type Reply struct {
	Status       Status          `json:"status"`
	ErrorCode    string          `json:"errorCode,omitempty"`
	ErrorMessage string          `json:"errorMessage,omitempty"`
	ErrorDetails *ErrorDetails   `json:"errorDetails,omitempty"`
	Data         json.RawMessage `json:"data,omitempty"`
}

Reply is the envelope every endpoint answers with, shared by commands and queries.

On a failure the envelope travels in the BODY. The `Nats-Service-Error` headers are added alongside it, never as a replacement — so this struct is always the authority, and the micro transport's 500 is not the error's status.

type Resource

type Resource struct {
	// Base is what a list or a get returns without asking for anything.
	Base map[string]Field `json:"base"`
	// Includable is what `include` may add.
	Includable map[string]Field `json:"includable"`
	// Filterable is what `filter` may name.
	Filterable map[string]Field `json:"filterable"`
	// Sortable is what `sort` may name.
	Sortable []string `json:"sortable"`
	// Defaults are the sort, limit and maxLimit applied when the caller asks for none.
	Defaults Defaults `json:"defaults"`
	// Enums are the allowed values of the enum filterables, keyed by enum name.
	Enums map[string][]EnumValue `json:"enums"`
	// Discriminator is present on the three resources that have variants. It names the field
	// that selects one and lists the accepted values.
	Discriminator *Discriminator `json:"discriminator,omitempty"`
	// Variants are the per-variant whitelists, keyed by discriminator value.
	Variants map[string]Variant `json:"variants,omitempty"`
}

Resource is one resource's five whitelists.

DENY BY DEFAULT: a name that is not in one of these lists DOES NOT EXIST. It comes back as invalid_fields with errorDetails, never as a silently ignored lever — an ignored filter would return MORE data than asked for, which is the worst failure mode a read contract has.

THREE RESOURCES KEEP THEIR FIELDS SOMEWHERE ELSE

`comments`, `activity` and `subscriptions` are DISCRIMINATED: their Base, Includable and Filterable are EMPTY, and the real whitelists live per variant under Variants, selected by the discriminator field (`entityType`). Only Sortable and Defaults stay at this level.

Read them through ForVariant rather than reaching into the maps, or those three resources will look like they have no fields at all.

func (Resource) Coerce

func (r Resource) Coerce(name string, raw string) (any, error)

Coerce turns a filter value parsed from a string into the JSON type the contract declares.

It matters because the operator is decided by the SHAPE of the value and the comparison by its TYPE: `{"projectId": "15"}` is not the same request as `{"projectId": 15}`. A CLI only ever has strings, so without the contract it would have to guess — and guessing "looks like a number, send a number" breaks any string field whose values happen to be digits, like a project code.

func (Resource) FieldNames

func (r Resource) FieldNames() []string

FieldNames lists base ∪ includable, which is exactly what `fields` may name.

func (Resource) FilterableNames

func (r Resource) FilterableNames() []string

FilterableNames lists what `filter` may name.

func (Resource) ForVariant

func (r Resource) ForVariant(name string) Resource

ForVariant returns the resource as it applies to one variant.

For an undiscriminated resource it returns the resource unchanged, so callers need no special case. For a discriminated one:

  • a known variant name yields that variant's whitelists;
  • an EMPTY name yields the UNION of every variant.

The union is deliberate. Validation must never reject what the server would accept, and without a variant chosen there is no way to know which one applies — so the permissive answer is the only correct one. An unknown name is left to the server, which owns that rule.

func (Resource) IncludableNames

func (r Resource) IncludableNames() []string

IncludableNames lists what `include` may name.

func (Resource) Validate

func (r Resource) Validate(q List) error

Validate checks a list query against the resource's whitelists BEFORE it is published.

Every rejection here is one core would also make, with the same meaning — the point is only that it arrives without a round trip and can name the alternatives. It is deliberately conservative: it flags names that are certainly wrong and never invents a rule of its own, so it cannot refuse a query the server would have accepted.

Example

Checking a query against the server's own whitelists before publishing it.

Every rejection here is one core would also make, with the same meaning — it just arrives without a round trip and can name the alternatives.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/gravadigital/jiku-go"
)

func main() {
	var client *jiku.Client
	ctx := context.Background()

	contract, err := client.Contract(ctx) // meta.describe, cached per client
	if err != nil {
		log.Fatal(err)
	}
	tasks, err := contract.Resource("tasks") // suggests a near match if the name is wrong
	if err != nil {
		log.Fatal(err)
	}

	query := jiku.List{Filter: jiku.F{"projectId": 15}, Sort: []string{"-createdAt"}}
	if err := tasks.Validate(query); err != nil {
		log.Fatal(err) // names the bad name and lists what is allowed
	}
	fmt.Println(tasks.Defaults.MaxLimit)
}

func (Resource) VariantNames

func (r Resource) VariantNames() []string

VariantNames lists the discriminator values that have a variant, sorted.

type Status

type Status string

Status is the envelope's `status`. It is the only field that is always present.

const (
	StatusSuccess Status = "success"
	StatusFailure Status = "failure"
)

type TagGroup

type TagGroup struct {
	Key    string   `json:"key"`
	Values []string `json:"values"`
}

TagGroup is one entry of the requirements.tags reply: a key and the values in use for it.

type Variant

type Variant struct {
	Base       map[string]Field       `json:"base"`
	Includable map[string]Field       `json:"includable"`
	Filterable map[string]Field       `json:"filterable"`
	Enums      map[string][]EnumValue `json:"enums"`
}

Variant is one variant's whitelists. It has no Sortable or Defaults of its own — those are shared by the resource.

type ZitadelConfig

type ZitadelConfig struct {
	// Issuer is the Zitadel instance, e.g. https://id.grava.io.
	Issuer string `yaml:"issuer"`
	// ClientID of a Native app with the Device Code grant, for `jiku login`.
	ClientID string `yaml:"client_id"`
	// ProjectID is the Zitadel project. It is what puts the ROLES in the token, and the
	// callout reads the role to decide what you may do — so a token minted without it
	// connects to nothing.
	ProjectID string `yaml:"project_id"`
	// KeyFile is a service account JSON key, for unattended use. When set, the CLI
	// authenticates as that machine user instead of as a person.
	KeyFile string `yaml:"key_file"`
}

ZitadelConfig is the identity provider half of the config file.

Directories

Path Synopsis
Package auth obtains Zitadel access tokens for a Jiku bus connection.
Package auth obtains Zitadel access tokens for a Jiku bus connection.
cmd
jiku command
Command jiku is a command-line client for Jiku's NATS API.
Command jiku is a command-line client for Jiku's NATS API.
Package events consumes the domain events Jiku's core publishes (REQ-014).
Package events consumes the domain events Jiku's core publishes (REQ-014).
examples
events command
Consuming the domain event stream.
Consuming the domain event stream.
quickstart command
Quickstart: connect as a PERSON and read.
Quickstart: connect as a PERSON and read.
service command
A long-running service reading from Jiku.
A long-running service reading from Jiku.
tools
gendocs command
Command gendocs regenerates docs/commands.md from Jiku's own AsyncAPI command contract.
Command gendocs regenerates docs/commands.md from Jiku's own AsyncAPI command contract.

Jump to

Keyboard shortcuts

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